Membangun Node.js/TypeScript REST API, Bagian 3: MongoDB, Otentikasi, dan Pengujian Otomatis
Diterbitkan: 2022-03-11Pada titik ini dalam seri kami tentang cara membuat API REST Node.js dengan Express.js dan TypeScript, kami telah membangun back end yang berfungsi dan memisahkan kode kami ke dalam konfigurasi rute, layanan, middleware, pengontrol, dan model. Jika Anda siap untuk mengikuti dari sana, klon contoh repo dan jalankan git checkout toptal-article-02 .
REST API Dengan Mongoose, Authentication, dan Automated Testing
Dalam artikel ketiga dan terakhir ini, kami akan terus mengembangkan REST API kami dengan menambahkan:
- Mongoose untuk memungkinkan kita bekerja dengan MongoDB dan mengganti DAO dalam memori kita dengan database nyata.
- Kemampuan otentikasi dan izin sehingga konsumen API dapat menggunakan JSON Web Token (JWT) untuk mengakses titik akhir kami dengan aman.
- Pengujian otomatis menggunakan Mocha (kerangka pengujian), Chai (pustaka pernyataan), dan SuperTest (modul abstraksi HTTP) untuk membantu memeriksa regresi saat basis kode tumbuh dan berubah.
Sepanjang jalan, kami akan menambahkan pustaka validasi dan keamanan, mendapatkan beberapa pengalaman dengan Docker, dan menyarankan beberapa topik, pustaka, dan keterampilan lebih lanjut yang sebaiknya dijelajahi oleh pembaca dalam membangun dan memperluas REST API mereka sendiri.
Menginstal MongoDB Sebagai Kontainer
Mari kita mulai dengan mengganti database dalam memori kita dari artikel sebelumnya dengan yang asli.
Untuk membuat database lokal untuk pengembangan, kita dapat menginstal MongoDB secara lokal. Tetapi perbedaan antara lingkungan (distribusi dan versi OS, misalnya) dapat menimbulkan masalah. Untuk menghindarinya, kami akan menggunakan kesempatan ini untuk memanfaatkan alat standar industri: container Docker.
Satu-satunya hal yang perlu dilakukan pembaca adalah menginstal Docker dan kemudian menginstal Docker Compose. Setelah diinstal, menjalankan docker -v di terminal akan menghasilkan nomor versi Docker.
Sekarang, untuk menjalankan MongoDB, di root proyek kami, kami akan membuat file YAML bernama docker-compose.yml yang berisi berikut ini:
version: '3' services: mongo: image: mongo volumes: - ./data:/data/db ports: - "27017:27017"Docker Compose memungkinkan kita untuk menjalankan beberapa container sekaligus dengan satu file konfigurasi. Di akhir artikel ini, kita akan melihat cara menjalankan REST API di Docker juga, tetapi untuk saat ini, kita hanya akan menggunakannya untuk menjalankan MongoDB tanpa harus menginstalnya secara lokal:
sudo docker-compose up -d Perintah up akan memulai wadah yang ditentukan, mendengarkan pada port MongoDB standar 27017. Saklar -d akan melepaskan perintah dari terminal. Jika semuanya berjalan tanpa masalah, kita akan melihat pesan seperti ini:
Creating network "toptal-rest-series_default" with the default driver Creating toptal-rest-series_mongo_1 ... done Itu juga akan membuat direktori data baru di root proyek, jadi kita harus menambahkan baris data di .gitignore .
Sekarang, jika kita perlu mematikan wadah Docker MongoDB kita, kita hanya perlu menjalankan sudo docker-compose down dan kita akan melihat output berikut:
Stopping toptal-rest-series_mongo_1 ... done Removing toptal-rest-series_mongo_1 ... done Removing network toptal-rest-series_default Itu saja yang perlu kita ketahui untuk memulai backend Node.js/MongoDB REST API kita. Mari kita pastikan bahwa kita telah menggunakan sudo docker-compose up -d sehingga MongoDB siap untuk digunakan oleh aplikasi kita.
Menggunakan Mongoose untuk Mengakses MongoDB
Untuk berkomunikasi dengan MongoDB, bagian belakang kami akan memanfaatkan pustaka pemodelan data objek (ODM) yang disebut Mongoose. Meskipun Mongoose cukup mudah digunakan, ada baiknya memeriksa dokumentasi untuk mempelajari semua kemungkinan lanjutan yang ditawarkannya untuk proyek dunia nyata.
Untuk menginstal Mongoose, kami menggunakan yang berikut ini:
npm i mongoose Mari konfigurasikan layanan Mongoose untuk mengelola koneksi ke instans MongoDB kita. Karena layanan ini dapat dibagi di antara beberapa sumber daya, kami akan menambahkannya ke folder common proyek kami.
Konfigurasinya sangat mudah. Meskipun tidak sepenuhnya diperlukan, kami akan memiliki objek mongooseOptions untuk menyesuaikan opsi koneksi Mongoose berikut:
-
useNewUrlParser: Tanpa ini disetel ketrue, Mongoose mencetak peringatan penghentian. -
useUnifiedTopology: Dokumentasi Mongoose merekomendasikan pengaturan ini ketrueuntuk menggunakan mesin manajemen koneksi yang lebih baru. -
serverSelectionTimeoutMS: Untuk tujuan UX dari proyek demo ini, waktu yang lebih singkat daripada default 30 detik berarti bahwa setiap pembaca yang lupa untuk memulai MongoDB sebelum Node.js akan melihat umpan balik yang membantu tentang hal itu lebih cepat, alih-alih back end yang tampaknya tidak responsif . -
useFindAndModify: Menyetel ini kefalsejuga menghindari peringatan penghentian, tetapi disebutkan di bagian penghentian dokumentasi, daripada di antara opsi koneksi Mongoose. Lebih khusus lagi, ini menyebabkan Mongoose menggunakan fitur MongoDB asli yang lebih baru daripada shim Mongoose yang lebih lama.
Menggabungkan opsi-opsi itu dengan beberapa logika inisialisasi dan coba lagi, inilah file common/services/mongoose.service.ts terakhir:
import mongoose from 'mongoose'; import debug from 'debug'; const log: debug.IDebugger = debug('app:mongoose-service'); class MongooseService { private count = 0; private mongooseOptions = { useNewUrlParser: true, useUnifiedTopology: true, serverSelectionTimeoutMS: 5000, useFindAndModify: false, }; constructor() { this.connectWithRetry(); } getMongoose() { return mongoose; } connectWithRetry = () => { log('Attempting MongoDB connection (will retry if needed)'); mongoose .connect('mongodb://localhost:27017/api-db', this.mongooseOptions) .then(() => { log('MongoDB is connected'); }) .catch((err) => { const retrySeconds = 5; log( `MongoDB connection unsuccessful (will retry #${++this .count} after ${retrySeconds} seconds):`, err ); setTimeout(this.connectWithRetry, retrySeconds * 1000); }); }; } export default new MongooseService(); Pastikan untuk menjaga perbedaan antara fungsi connect() dari Mongoose dan fungsi layanan connectWithRetry() kita sendiri:
-
mongoose.connect()mencoba menyambung ke layanan MongoDB lokal kami (berjalan dengandocker-compose) dan akan habis waktu setelahserverSelectionTimeoutMSmilidetik. -
MongooseService.connectWithRetry()mencoba ulang hal di atas jika aplikasi kita dimulai tetapi layanan MongoDB belum berjalan. Karena dalam konstruktor tunggal,connectWithRetry()hanya akan dijalankan sekali, tetapi akan mencoba lagi panggilanconnect()tanpa batas, dengan jedaretrySecondsdetik setiap kali timeout terjadi.
Langkah kita selanjutnya adalah mengganti database dalam memori kita sebelumnya dengan MongoDB!
Menghapus Basis Data Dalam Memori Kami dan Menambahkan MongoDB
Sebelumnya, kami menggunakan database dalam memori untuk memungkinkan kami fokus pada modul lain yang sedang kami bangun. Untuk menggunakan Mongoose sebagai gantinya, kita harus sepenuhnya users.dao.ts . Kita memerlukan satu pernyataan import lagi, untuk memulai:
import mongooseService from '../../common/services/mongoose.service'; Sekarang mari kita hapus semuanya dari definisi kelas UsersDao kecuali konstruktor. Kita dapat mulai mengisinya kembali dengan membuat Schema pengguna untuk Mongoose sebelum konstruktor:
Schema = mongooseService.getMongoose().Schema; userSchema = new this.Schema({ _id: String, email: String, password: { type: String, select: false }, firstName: String, lastName: String, permissionFlags: Number, }, { id: false }); User = mongooseService.getMongoose().model('Users', this.userSchema); Ini mendefinisikan koleksi MongoDB kami dan menambahkan fitur khusus yang tidak dimiliki database dalam memori kami select: false di bidang password akan menyembunyikan bidang ini setiap kali kami mendapatkan pengguna atau mencantumkan semua pengguna.
Skema pengguna kami mungkin terlihat familier karena mirip dengan entitas DTO kami. Perbedaan utama adalah bahwa kami mendefinisikan bidang mana yang harus ada dalam koleksi MongoDB kami yang disebut Users , sedangkan entitas DTO menentukan bidang mana yang akan diterima dalam permintaan HTTP.
Bagian dari pendekatan kami tidak berubah, karenanya masih mengimpor tiga DTO kami di bagian atas users.dao.ts . Tetapi sebelum menerapkan operasi metode CRUD kami, kami akan memperbarui DTO kami dalam dua cara.
DTO Ubah No. 1: id vs. _id
Karena Mongoose secara otomatis menyediakan bidang _id , kami akan menghapus bidang id dari DTO. Itu akan datang dari parameter dari permintaan rute.
Berhati-hatilah karena model Mongoose menyediakan pengambil id virtual secara default, jadi kami telah menonaktifkan opsi di atas dengan { id: false } untuk menghindari kebingungan. Tapi itu merusak referensi kami ke user.id di middleware pengguna kami validateSameEmailBelongToSameUser() —kami membutuhkan user._id di sana.
Beberapa database menggunakan id konvensi, dan yang lain menggunakan _id , jadi tidak ada antarmuka yang sempurna. Untuk proyek contoh kami menggunakan Mongoose, kami hanya memperhatikan yang mana yang kami gunakan pada titik mana dalam kode, tetapi ketidakcocokan masih akan diperlihatkan kepada konsumen API:
Kami meninggalkannya sebagai latihan bagi pembaca untuk menerapkan salah satu dari banyak solusi dunia nyata yang tersedia di akhir proyek.
Perubahan DTO No. 2: Mempersiapkan Izin Berbasis Bendera
Kami juga akan mengganti nama permissionLevel menjadi permissionFlags di DTO untuk mencerminkan sistem izin yang lebih canggih yang akan kami terapkan, serta definisi userSchema Mongoose di atas.
DTO: Bagaimana dengan Prinsip KERING?
Ingat, DTO hanya berisi bidang yang ingin kita lewati antara klien API dan database kita. Ini mungkin tampak disayangkan karena ada beberapa tumpang tindih antara model dan DTO tetapi berhati-hatilah untuk terlalu memaksakan DRY dengan mengorbankan "keamanan secara default." Jika menambahkan bidang hanya memerlukan penambahannya di satu tempat, pengembang mungkin tanpa disadari mengeksposnya di API ketika itu dimaksudkan hanya untuk internal. Itu karena prosesnya tidak memaksa mereka untuk berpikir tentang penyimpanan data dan transfer data sebagai dua konteks terpisah dengan dua rangkaian persyaratan yang berpotensi berbeda.
Setelah perubahan DTO kami selesai, kami dapat mengimplementasikan operasi metode CRUD kami (setelah konstruktor UsersDao ), dimulai dengan create :
async addUser(userFields: CreateUserDto) { const userId = shortid.generate(); const user = new this.User({ _id: userId, ...userFields, permissionFlags: 1, }); await user.save(); return userId; } Perhatikan bahwa apa pun yang dikirim konsumen API untuk permissionFlags melalui userFields , kami kemudian menimpanya dengan nilai 1 .
Selanjutnya kita telah membaca , fungsionalitas dasar untuk mendapatkan pengguna dengan ID, mendapatkan pengguna melalui email, dan membuat daftar pengguna dengan pagination:
async getUserByEmail(email: string) { return this.User.findOne({ email: email }).exec(); } async getUserById(userId: string) { return this.User.findOne({ _id: userId }).populate('User').exec(); } async getUsers(limit = 25, page = 0) { return this.User.find() .limit(limit) .skip(limit * page) .exec(); } Untuk memperbarui pengguna, satu fungsi DAO akan cukup karena fungsi Mongoose findOneAndUpdate() yang mendasarinya dapat memperbarui seluruh dokumen atau hanya sebagian saja. Perhatikan bahwa fungsi kita sendiri akan mengambil userFields sebagai PatchUserDto atau PutUserDto , menggunakan tipe gabungan TypeScript (ditandai dengan | ):
async updateUserById( userId: string, userFields: PatchUserDto | PutUserDto ) { const existingUser = await this.User.findOneAndUpdate( { _id: userId }, { $set: userFields }, { new: true } ).exec(); return existingUser; } Opsi new: true memberi tahu Mongoose untuk mengembalikan objek seperti apa adanya setelah pembaruan, bukan seperti semula.
Hapus ringkas dengan Mongoose:
async removeUserById(userId: string) { return this.User.deleteOne({ _id: userId }).exec(); } Pembaca mungkin memperhatikan bahwa setiap panggilan ke fungsi anggota User dirantai ke panggilan exec() . Ini opsional, tetapi pengembang Mongoose merekomendasikannya karena menyediakan jejak tumpukan yang lebih baik saat debugging.
Setelah mengkodekan DAO kami, kami perlu sedikit memperbarui users.service.ts kami dari artikel terakhir kami agar sesuai dengan fungsi baru. Tidak perlu refactoring besar, cukup tiga sentuhan:
@@ -16,3 +16,3 @@ class UsersService implements CRUD { async list(limit: number, page: number) { - return UsersDao.getUsers(); + return UsersDao.getUsers(limit, page); } @@ -20,3 +20,3 @@ class UsersService implements CRUD { async patchById(id: string, resource: PatchUserDto): Promise<any> { - return UsersDao.patchUserById(id, resource); + return UsersDao.updateUserById(id, resource); } @@ -24,3 +24,3 @@ class UsersService implements CRUD { async putById(id: string, resource: PutUserDto): Promise<any> { - return UsersDao.putUserById(id, resource); + return UsersDao.updateUserById(id, resource); } Sebagian besar pemanggilan fungsi tetap sama persis, karena ketika kami memfaktorkan ulang UsersDao , kami mempertahankan struktur yang kami buat di artikel sebelumnya. Tapi mengapa pengecualian?
- Kami menggunakan
updateUserById()untukPUTdanPATCHseperti yang kami sebutkan di atas. (Seperti yang disebutkan di Bagian 2, kami mengikuti implementasi REST API tipikal daripada mencoba mematuhi RFC tertentu ke surat itu. Antara lain, ini berarti tidak meminta permintaanPUTmembuat entitas baru jika tidak ada; dengan cara ini, bagian belakang kami tidak menyerahkan kendali pembuatan ID kepada konsumen API.) - Kami meneruskan parameter
limitdanpagekegetUsers()karena implementasi DAO baru kami akan menggunakannya.
Struktur utama di sini adalah pola yang cukup kokoh. Misalnya, ini dapat digunakan kembali jika pengembang ingin menukar Mongoose dan MongoDB untuk sesuatu seperti TypeORM dan PostgreSQL. Seperti di atas, penggantian semacam itu hanya memerlukan pemfaktoran ulang fungsi individu DAO sambil mempertahankan tanda tangannya agar sesuai dengan kode lainnya.
Menguji REST API kami yang didukung luwak
Mari luncurkan back end API dengan npm start . Kami kemudian akan mencoba membuat pengguna:
curl --request POST 'localhost:3000/users' \ --header 'Content-Type: application/json' \ --data-raw '{ "password":"secr3tPass!23", "email":"[email protected]" }'Objek respons berisi ID pengguna baru:
{ "id": "7WYQoVZ3E" }Seperti pada artikel sebelumnya, pengujian manual yang tersisa akan lebih mudah menggunakan variabel lingkungan:
REST_API_EXAMPLE_Memperbarui pengguna terlihat seperti ini:
curl --include --request PATCH "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "firstName": "Marcos", "lastName": "Silva" }' Responsnya harus dimulai dengan HTTP/1.1 204 No Content . (Tanpa sakelar --include , tidak ada respons yang akan dicetak, yang sejalan dengan implementasi kami.)
Jika sekarang kami meminta pengguna untuk memeriksa pembaruan di atas ... :
curl --request GET "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "firstName": "Marcos", "lastName": "Silva" }' … tanggapan menunjukkan bidang yang diharapkan, termasuk bidang _id yang dibahas di atas:
{ "_id": "7WYQoVZ3E", "email": "[email protected]", "permissionFlags": 1, "__v": 0, "firstName": "Marcos", "lastName": "Silva" } Ada juga bidang khusus, __v , yang digunakan oleh Mongoose untuk membuat versi; itu akan bertambah setiap kali catatan ini diperbarui.
Selanjutnya, mari daftar pengguna:
curl --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json' Respons yang diharapkan sama, hanya dibungkus [] .
Sekarang kata sandi kita disimpan dengan aman, mari pastikan kita dapat menghapus pengguna:
curl --include --request DELETE "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json'Kami mengharapkan 204 tanggapan lagi.
Pembaca mungkin bertanya-tanya apakah bidang kata sandi berfungsi dengan benar, karena select: false dalam definisi Schema Mongoose menyembunyikannya dari keluaran GET kami sebagaimana dimaksud. Mari ulangi POST awal kita untuk membuat pengguna lagi, lalu periksa. (Jangan lupa untuk menyimpan ID baru untuk nanti.)
Kata Sandi Tersembunyi dan Debugging Data Langsung Dengan Wadah MongoDB
Untuk memeriksa apakah kata sandi disimpan dengan aman (yaitu, di-hash, bukan dalam teks biasa), pengembang dapat memeriksa data MongoDB secara langsung. Salah satu caranya adalah dengan mengakses klien CLI mongo standar dari dalam wadah Docker yang sedang berjalan:
sudo docker exec -it toptal-rest-series_mongo_1 mongo Dari sana, mengeksekusi use api-db diikuti oleh db.users.find().pretty() akan mencantumkan semua data pengguna, termasuk kata sandi.
Mereka yang lebih suka GUI dapat menginstal klien MongoDB terpisah seperti Robo 3T:
Awalan kata sandi ( $argon2... ) adalah bagian dari format string PHC, dan sengaja disimpan tanpa dimodifikasi: Fakta bahwa Argon2 dan parameter umumnya disebutkan tidak akan membantu peretas untuk menentukan kata sandi asli jika mereka berhasil mencuri basis data. Kata sandi yang disimpan dapat diperkuat lebih lanjut menggunakan penggaraman, teknik yang akan kita gunakan di bawah ini dengan JWT. Kami membiarkannya sebagai latihan bagi pembaca untuk menerapkan penggaraman di atas dan memeriksa perbedaan antara nilai yang disimpan ketika dua pengguna memasukkan kata sandi yang sama.
Kami sekarang tahu Mongoose berhasil mengirim data ke database MongoDB kami. Tapi bagaimana kita tahu bahwa konsumen API kita akan mengirimkan data yang sesuai dalam permintaan mereka ke rute pengguna kita?
Menambahkan validator ekspres
Ada beberapa cara untuk mencapai validasi lapangan. Dalam artikel ini kita akan menggunakan express-validator, yang cukup stabil, mudah digunakan, dan didokumentasikan dengan baik. Meskipun kita dapat menggunakan fungsionalitas validasi yang disertakan dengan Mongoose, validator ekspres menyediakan fitur tambahan. Misalnya, ia datang dengan validator out-of-the-box untuk alamat email, yang di Mongoose akan mengharuskan kita untuk membuat kode validator khusus.
Mari kita instal:
npm i express-validator Untuk menyetel bidang yang ingin kami validasi, kami akan menggunakan metode body() yang akan kami impor di users.routes.config.ts kami. Metode body() akan memvalidasi bidang dan menghasilkan daftar kesalahan—disimpan dalam objek express.Request —jika terjadi kegagalan.
Kami kemudian membutuhkan middleware kami sendiri untuk memeriksa dan memanfaatkan daftar kesalahan. Karena logika ini mungkin bekerja dengan cara yang sama untuk rute yang berbeda, mari buat common/middleware/body.validation.middleware.ts dengan yang berikut:
import express from 'express'; import { validationResult } from 'express-validator'; class BodyValidationMiddleware { verifyBodyFieldsErrors( req: express.Request, res: express.Response, next: express.NextFunction ) { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).send({ errors: errors.array() }); } next(); } } export default new BodyValidationMiddleware(); Dengan itu, kami siap menangani kesalahan apa pun yang dihasilkan dari fungsi body() . Mari tambahkan kembali berikut ini di users.routes.config.ts :
import BodyValidationMiddleware from '../common/middleware/body.validation.middleware'; import { body } from 'express-validator';Sekarang kami dapat memperbarui rute kami dengan yang berikut:
@@ -15,3 +17,6 @@ export class UsersRoutes extends CommonRoutesConfig { .post( - UsersMiddleware.validateRequiredUserBodyFields, + body('email').isEmail(), + body('password') + .isLength({ min: 5 }) + .withMessage('Must include password (5+ characters)'), + BodyValidationMiddleware.verifyBodyFieldsErrors, UsersMiddleware.validateSameEmailDoesntExist, @@ -28,3 +33,10 @@ export class UsersRoutes extends CommonRoutesConfig { this.app.put(`/users/:userId`, [ - UsersMiddleware.validateRequiredUserBodyFields, + body('email').isEmail(), + body('password') + .isLength({ min: 5 }) + .withMessage('Must include password (5+ characters)'), + body('firstName').isString(), + body('lastName').isString(), + body('permissionFlags').isInt(), + BodyValidationMiddleware.verifyBodyFieldsErrors, UsersMiddleware.validateSameEmailBelongToSameUser, @@ -34,2 +46,11 @@ export class UsersRoutes extends CommonRoutesConfig { this.app.patch(`/users/:userId`, [ + body('email').isEmail().optional(), + body('password') + .isLength({ min: 5 }) + .withMessage('Password must be 5+ characters') + .optional(), + body('firstName').isString().optional(), + body('lastName').isString().optional(), + body('permissionFlags').isInt().optional(), + BodyValidationMiddleware.verifyBodyFieldsErrors, UsersMiddleware.validatePatchEmail, Pastikan untuk menambahkan BodyValidationMiddleware.verifyBodyFieldsErrors di setiap rute setelah setiap baris body() yang ada, jika tidak, tidak akan ada yang berpengaruh.
Perhatikan bagaimana kami telah memperbarui rute POST dan PUT kami untuk menggunakan validator ekspres alih-alih fungsi validateRequiredUserBodyFields buatan kami. Karena rute ini adalah satu-satunya yang menggunakan fungsi ini, implementasinya dapat dihapus dari users.middleware.ts .
Itu dia! Pembaca dapat memulai ulang Node.js dan mencoba hasilnya menggunakan klien REST favorit mereka untuk melihat bagaimana Node.js menangani berbagai masukan. Jangan lupa untuk menjelajahi dokumentasi validator ekspres untuk kemungkinan lebih lanjut; contoh kita hanyalah titik awal untuk validasi permintaan.
Data yang valid merupakan salah satu aspek yang harus dipastikan; pengguna dan tindakan yang valid adalah hal lain.
Alur Otentikasi vs. Izin (atau “Otorisasi”)
Aplikasi Node.js kami mengekspos satu set lengkap users/ titik akhir, memungkinkan konsumen API untuk membuat, memperbarui, dan membuat daftar pengguna. Tetapi setiap titik akhir memungkinkan akses publik tanpa batas. Ini adalah pola umum untuk mencegah pengguna mengubah data satu sama lain dan orang luar mengakses titik akhir apa pun yang tidak kami inginkan untuk publik.
Ada dua aspek utama yang terlibat dalam pembatasan ini dan keduanya disingkat menjadi "auth." Otentikasi adalah tentang dari siapa permintaan itu berasal dan otorisasi adalah tentang apakah mereka diizinkan untuk melakukan apa yang mereka minta. Sangat penting untuk tetap mengetahui mana yang sedang dibahas. Bahkan tanpa formulir pendek, kode respons HTTP standar berhasil mengacaukan masalah: 401 Unauthorized adalah tentang otentikasi dan 403 Forbidden tentang otorisasi. Kami akan melakukan kesalahan di sisi "auth" yang berarti "otentikasi" dalam nama modul, dan menggunakan "izin" untuk masalah otorisasi.
Bahkan tanpa formulir pendek, kode respons HTTP standar berhasil mengacaukan masalah:
401 Unauthorizedadalah tentang otentikasi dan403 Forbiddententang otorisasi.
Ada banyak pendekatan otentikasi untuk dijelajahi, termasuk penyedia identitas pihak ketiga drop-in seperti Auth0. Dalam artikel ini, kami telah memilih implementasi yang mendasar tetapi dapat diskalakan. Ini didasarkan pada JWT.
JWT terdiri dari JSON terenkripsi dengan beberapa metadata yang tidak terkait dengan otentikasi, yang dalam kasus kami mencakup alamat email pengguna dan tanda izin. JSON juga akan berisi rahasia untuk memverifikasi integritas metadata.
Idenya adalah meminta klien untuk mengirim JWT yang valid di dalam setiap permintaan non-publik. Ini memungkinkan kami memverifikasi bahwa klien baru-baru ini memiliki kredensial yang valid untuk titik akhir yang ingin mereka gunakan, tanpa harus mengirim kredensial itu sendiri melalui kabel dengan setiap permintaan.
Tapi di mana ini akan cocok dengan basis kode API contoh kita? Mudah: dengan middleware yang dapat kita gunakan dalam konfigurasi rute kita!
Menambahkan Modul Otentikasi
Mari kita konfigurasikan dulu apa yang akan ada di JWT kita. Di sinilah kita akan mulai menggunakan bidang permissionFlags dari sumber daya pengguna kita, tetapi hanya karena metadata yang nyaman untuk dienkripsi dalam JWT—bukan karena JWT secara inheren ada hubungannya dengan logika izin berbutir halus.
Sebelum membuat middleware penghasil JWT, kita perlu menambahkan fungsi khusus ke users.dao.ts untuk mengambil bidang kata sandi, karena kita menyetel Mongoose untuk biasanya menghindari pengambilannya:
async getUserByEmailWithPassword(email: string) { return this.User.findOne({ email: email }) .select('_id email permissionFlags +password') .exec(); } Dan di users.service.ts :
async getUserByEmailWithPassword(email: string) { return UsersDao.getUserByEmailWithPassword(email); } Sekarang, mari buat folder auth di root proyek kita—kita akan menambahkan titik akhir untuk memungkinkan konsumen API menghasilkan JWT. Pertama, mari kita buat middleware untuknya di auth/middleware/auth.middleware.ts , sebagai singleton bernama AuthMiddleware .
Kami membutuhkan beberapa import s:
import express from 'express'; import usersService from '../../users/services/users.service'; import * as argon2 from 'argon2'; Di kelas AuthMiddleware , kami akan membuat fungsi middleware untuk memeriksa apakah pengguna API telah menyertakan kredensial login yang valid dengan permintaan mereka:
async verifyUserPassword( req: express.Request, res: express.Response, next: express.NextFunction ) { const user: any = await usersService.getUserByEmailWithPassword( req.body.email ); if (user) { const passwordHash = user.password; if (await argon2.verify(passwordHash, req.body.password)) { req.body = { userId: user._id, email: user.email, permissionFlags: user.permissionFlags, }; return next(); } } // Giving the same message in both cases // helps protect against cracking attempts: res.status(400).send({ errors: ['Invalid email and/or password'] }); } Adapun middleware untuk memastikan email dan password ada di req.body , kami akan menggunakan express-validator ketika nanti kami mengonfigurasi rute untuk menggunakan fungsi verifyUserPassword() di atas.
Menyimpan Rahasia JWT
Untuk menghasilkan JWT, kita memerlukan rahasia JWT, yang akan kita gunakan untuk menandatangani JWT yang dihasilkan dan juga untuk memvalidasi JWT yang masuk dari permintaan klien. Daripada melakukan hard-code nilai rahasia JWT dalam file TypeScript, kami akan menyimpannya dalam file "variabel lingkungan" terpisah, .env , yang tidak boleh didorong ke repositori kode .
Seperti praktik umum, kami telah menambahkan file .env.example ke repo untuk membantu pengembang memahami variabel mana yang diperlukan saat membuat .env . Dalam kasus kami, kami ingin variabel yang disebut JWT_SECRET menyimpan rahasia JWT kami sebagai string. Pembaca yang menunggu hingga akhir artikel ini dan menggunakan cabang terakhir dari repo perlu mengingat untuk mengubah nilai ini secara lokal .
Proyek dunia nyata terutama perlu mengikuti praktik terbaik JWT dengan membedakan rahasia JWT menurut lingkungan (pengembangan, pementasan, produksi, dll.).
File .env kami (di root proyek) harus menggunakan format berikut tetapi tidak boleh mempertahankan nilai rahasia yang sama:
JWT_SECRET=My!@!Se3cr8tH4sh3Cara mudah untuk memuat variabel-variabel ini ke dalam aplikasi kita adalah dengan menggunakan pustaka yang disebut dotenv:
npm i dotenv Satu-satunya konfigurasi yang diperlukan adalah memanggil fungsi dotenv.config() segera setelah kita meluncurkan aplikasi kita. Di bagian paling atas app.ts , kami akan menambahkan:
import dotenv from 'dotenv'; const dotenvResult = dotenv.config(); if (dotenvResult.error) { throw dotenvResult.error; }Pengontrol Otentikasi
Prasyarat generasi JWT terakhir adalah menginstal perpustakaan jsonwebtoken dan tipe TypeScript-nya:
npm i jsonwebtoken npm i --save-dev @types/jsonwebtoken Sekarang, mari kita buat pengontrol /auth di auth/controllers/auth.controller.ts . Kita tidak perlu mengimpor pustaka dotenv di sini karena mengimpornya di app.ts membuat konten file .env tersedia di seluruh aplikasi melalui objek global Node.js yang disebut process :
import express from 'express'; import debug from 'debug'; import jwt from 'jsonwebtoken'; import crypto from 'crypto'; const log: debug.IDebugger = debug('app:auth-controller'); /** * This value is automatically populated from .env, a file which you will have * to create for yourself at the root of the project. * * See .env.example in the repo for the required format. */ // @ts-expect-error const jwtSecret: string = process.env.JWT_SECRET; const tokenExpirationInSeconds = 36000; class AuthController { async createJWT(req: express.Request, res: express.Response) { try { const refreshId = req.body.userId + jwtSecret; const salt = crypto.createSecretKey(crypto.randomBytes(16)); const hash = crypto .createHmac('sha512', salt) .update(refreshId) .digest('base64'); req.body.refreshKey = salt.export(); const token = jwt.sign(req.body, jwtSecret, { expiresIn: tokenExpirationInSeconds, }); return res .status(201) .send({ accessToken: token, refreshToken: hash }); } catch (err) { log('createJWT error: %O', err); return res.status(500).send(); } } } export default new AuthController(); Pustaka jsonwebtoken akan menandatangani token baru dengan jwtSecret kami. Kami juga akan membuat salt dan hash menggunakan modul crypto asli Node.js, kemudian menggunakannya untuk membuat refreshToken yang dengannya konsumen API dapat menyegarkan JWT saat ini—pengaturan yang sangat baik untuk diterapkan pada aplikasi dapat skala.
Apa perbedaan antara refreshKey , refreshToken , dan accessToken ? *Token s dikirim ke konsumen API kami dengan gagasan bahwa accessToken digunakan untuk permintaan apa pun di luar apa yang tersedia untuk masyarakat umum, dan refreshToken digunakan untuk meminta penggantian accessToken yang kedaluwarsa. refreshKey , di sisi lain, digunakan untuk meneruskan variabel salt —dienkripsi dalam refreshToken —kembali ke middleware penyegaran kita, yang akan kita bahas di bawah.
Perhatikan bahwa implementasi kami memiliki jsonwebtoken menangani kedaluwarsa token untuk kami. Jika JWT kedaluwarsa, klien perlu mengautentikasi sekali lagi.
Rute Otentikasi REST API Node.js Awal
Mari konfigurasikan titik akhir sekarang di auth/auth.routes.config.ts :
import { CommonRoutesConfig } from '../common/common.routes.config'; import authController from './controllers/auth.controller'; import authMiddleware from './middleware/auth.middleware'; import express from 'express'; import BodyValidationMiddleware from '../common/middleware/body.validation.middleware'; import { body } from 'express-validator'; export class AuthRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'AuthRoutes'); } configureRoutes(): express.Application { this.app.post(`/auth`, [ body('email').isEmail(), body('password').isString(), BodyValidationMiddleware.verifyBodyFieldsErrors, authMiddleware.verifyUserPassword, authController.createJWT, ]); return this.app; } } Dan, jangan lupa untuk menambahkannya ke file app.ts kami:
// ... import { AuthRoutes } from './auth/auth.routes.config'; // ... routes.push(new AuthRoutes(app)); // independent: can go before or after UsersRoute // ...Kami siap untuk memulai ulang Node.js dan menguji sekarang, memastikan kami cocok dengan kredensial apa pun yang kami gunakan untuk membuat pengguna uji kami sebelumnya:
curl --request POST 'localhost:3000/auth' \ --header 'Content-Type: application/json' \ --data-raw '{ "password":"secr3tPass!23", "email":"[email protected]" }'Responsnya akan seperti:
{ "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJVZGdzUTBYMXciLCJlbWFpbCI6Im1hcmNvcy5oZW5yaXF1ZUB0b3B0YWwuY29tIiwicHJvdmlkZXIiOiJlbWFpbCIsInBlcm1pc3Npb25MZXZlbCI6MSwicmVmcmVzaEtleSI6ImtDN3JFdDFHUmNsWTVXM0N4dE9nSFE9PSIsImlhdCI6MTYxMTM0NTYzNiwiZXhwIjoxNjExMzgxNjM2fQ.cfI_Ey4RHKbOKFdVGsowePZlMeX3fku6WHFu0EMjFP8", "refreshToken": "cXBHZ2tJdUhucERaTVpMWVNTckhNenQwcy9Bd0VIQ2RXRnA4bVBJbTBuQVorcS9Qb2xOUDVFS2xEM1RyNm1vTGdoWWJqb2xtQ0NHcXhlWERUcG81d0E9PQ==" }Seperti sebelumnya, mari kita atur beberapa variabel lingkungan untuk kenyamanan menggunakan nilai di atas:

REST_API_EXAMPLE_ACCESS="put_your_access_token_here" REST_API_EXAMPLE_REFRESH="put_your_refresh_token_here"Besar! Kami memiliki token akses dan token penyegaran, tetapi kami membutuhkan beberapa middleware yang dapat melakukan sesuatu yang berguna dengan mereka.
JWT Middleware
Kita membutuhkan tipe TypeScript baru untuk menangani struktur JWT dalam bentuk decode-nya. Buat common/types/jwt.ts dengan ini di dalamnya:
export type Jwt = { refreshKey: string; userId: string; permissionFlags: string; }; Mari kita terapkan fungsi middleware untuk memeriksa keberadaan token penyegaran, untuk memverifikasi token penyegaran, dan untuk memverifikasi JWT. Ketiganya bisa masuk ke file baru, auth/middleware/jwt.middleware.ts :
import express from 'express'; import jwt from 'jsonwebtoken'; import crypto from 'crypto'; import { Jwt } from '../../common/types/jwt'; import usersService from '../../users/services/users.service'; // @ts-expect-error const jwtSecret: string = process.env.JWT_SECRET; class JwtMiddleware { verifyRefreshBodyField( req: express.Request, res: express.Response, next: express.NextFunction ) { if (req.body && req.body.refreshToken) { return next(); } else { return res .status(400) .send({ errors: ['Missing required field: refreshToken'] }); } } async validRefreshNeeded( req: express.Request, res: express.Response, next: express.NextFunction ) { const user: any = await usersService.getUserByEmailWithPassword( res.locals.jwt.email ); const salt = crypto.createSecretKey( Buffer.from(res.locals.jwt.refreshKey.data) ); const hash = crypto .createHmac('sha512', salt) .update(res.locals.jwt.userId + jwtSecret) .digest('base64'); if (hash === req.body.refreshToken) { req.body = { userId: user._id, email: user.email, permissionFlags: user.permissionFlags, }; return next(); } else { return res.status(400).send({ errors: ['Invalid refresh token'] }); } } validJWTNeeded( req: express.Request, res: express.Response, next: express.NextFunction ) { if (req.headers['authorization']) { try { const authorization = req.headers['authorization'].split(' '); if (authorization[0] !== 'Bearer') { return res.status(401).send(); } else { res.locals.jwt = jwt.verify( authorization[1], jwtSecret ) as Jwt; next(); } } catch (err) { return res.status(403).send(); } } else { return res.status(401).send(); } } } export default new JwtMiddleware(); The validRefreshNeeded() function also verifies if the refresh token is correct for a specific user ID. If it is, then below we'll reuse authController.createJWT to generate a new JWT for the user.
We also have validJWTNeeded() , which validates whether the API consumer sent a valid JWT in the HTTP headers respecting the convention Authorization: Bearer <token> . (Yes, that's another unfortunate “auth” conflation.)
Now to configure a new route for refreshing the token and the permission flags encoded within it.
JWT Refresh Route
In auth.routes.config.ts we'll import our new middleware:
import jwtMiddleware from './middleware/jwt.middleware';Then we'll add the following route:
this.app.post(`/auth/refresh-token`, [ jwtMiddleware.validJWTNeeded, jwtMiddleware.verifyRefreshBodyField, jwtMiddleware.validRefreshNeeded, authController.createJWT, ]); Now we can test if it is working properly with the accessToken and refreshToken we received earlier:
curl --request POST 'localhost:3000/auth/refresh-token' \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS" \ --data-raw "{ \"refreshToken\": \"$REST_API_EXAMPLE_REFRESH\" }" We should expect to receive a new accessToken and a new refreshToken to be used later. We leave it as an exercise for the reader to ensure that the back end invalidates previous tokens and limits how often new ones can be requested.
Now our API consumers are able to create, validate, and refresh JWTs. Let's look at some permissions concepts, then implement one and integrate it with our JWT middleware in our user routes.
User Permissions
Once we know who an API client is, we want to know whether they're allowed to use the resource they're requesting. It's quite common to manage combinations of permissions for each user. Without adding much complexity, this allows for more flexibility than a traditional “access level” strategy. Regardless of the business logic we use for each permission, it's quite straightforward to create a generic way to handle it.
Bitwise AND ( & ) and Powers of Two
To manage permissions, we'll leverage JavaScript's built-in bitwise AND operator, & . This approach lets us store a whole set of permissions information as a single, per-user number, with each of its binary digits representing whether the user has permission to do something. But there's no need to worry about the math behind it too much—the point is that it's easy to use.
All we need to do is define each kind of permission (a permission flag ) as a power of 2 (1, 2, 4, 8, 16, 32, …). Then we can attach business logic to each flag, up to a maximum of 31 flags. For example, an audio-accessible, international blog might have these permissions:
- 1: Authors can edit text.
- 2: Illustrators can replace illustrations.
- 4: Narrators can replace the audio file corresponding to any paragraph.
- 8: Translators can edit translations.
This approach allows for all sorts of permission flag combinations for users:
- An author's (or editor's) permission flags value will be just the number 1.
- An illustrator's permission flags will be the number 2. But some authors are also illustrators. In that case, we sum the relevant permissions values: 1 + 2 = 3.
- A narrator's flags will be 4. In the case of an author who narrates their own work, it will be 1 + 4 = 5. If they also illustrate, it's 1 + 2 + 4 = 7.
- A translator will have a permission value of 8. Multilingual authors would then have flags of 1 + 8 = 9. A translator who also narrates (but is not an author) would have 4 + 8 = 12.
- If we want to have a sudo admin, having all combined permissions, we can simply use 2,147,483,647, which is the maximum safe value for a 32-bit integer.
Readers can test this logic as plain JavaScript:
- User with permission 5 trying to edit text (permission flag 1):
Input: 5 & 1
Output: 1
- User with permission 1 trying to narrate (permission flag 4):
Input: 1 & 4
Output: 0
- User with permission 12 trying to narrate:
Input: 12 & 4
Output: 4
When the output is 0, we block the user; otherwise, we let them access what they are trying to access.
Permission Flag Implementation
We'll store permissions flags inside the common folder since the business logic can be shared with future modules. Let's start by adding an enum to hold some permission flags at common/middleware/common.permissionflag.enum.ts :
export enum PermissionFlag { FREE_PERMISSION = 1, PAID_PERMISSION = 2, ANOTHER_PAID_PERMISSION = 4, ADMIN_PERMISSION = 8, ALL_PERMISSIONS = 2147483647, }Note: Since this is an example project, we kept the flag names fairly generic.
Before we forget, now's a good time for a quick return to the addUser() function in our user DAO to replace our temporary magic number 1 with PermissionFlag.FREE_PERMISSION . We'll also need a corresponding import statement.
We can also import it into a new middleware file at common/middleware/common.permission.middleware.ts with a singleton class named CommonPermissionMiddleware :
import express from 'express'; import { PermissionFlag } from './common.permissionflag.enum'; import debug from 'debug'; const log: debug.IDebugger = debug('app:common-permission-middleware');Instead of creating several similar middleware functions, we'll use the factory pattern to create a special factory method (or factory function or simply factory ). Our factory function will allow us to generate—at the time of route configuration—middleware functions to check for any permission flag needed. With that, we avoid having to manually duplicate our middleware function whenever we add a new permission flag.
Here's the factory that will generate a middleware function that checks for whatever permission flag we pass it:
permissionFlagRequired(requiredPermissionFlag: PermissionFlag) { return ( req: express.Request, res: express.Response, next: express.NextFunction ) => { try { const userPermissionFlags = parseInt( res.locals.jwt.permissionFlags ); if (userPermissionFlags & requiredPermissionFlag) { next(); } else { res.status(403).send(); } } catch (e) { log(e); } }; }Kasus yang lebih disesuaikan adalah bahwa satu-satunya pengguna yang dapat mengakses catatan pengguna tertentu adalah pengguna atau admin yang sama:
async onlySameUserOrAdminCanDoThisAction( req: express.Request, res: express.Response, next: express.NextFunction ) { const userPermissionFlags = parseInt(res.locals.jwt.permissionFlags); if ( req.params && req.params.userId && req.params.userId === res.locals.jwt.userId ) { return next(); } else { if (userPermissionFlags & PermissionFlag.ADMIN_PERMISSION) { return next(); } else { return res.status(403).send(); } } } Kami akan menambahkan satu middleware terakhir, kali ini di users.middleware.ts :
async userCantChangePermission( req: express.Request, res: express.Response, next: express.NextFunction ) { if ( 'permissionFlags' in req.body && req.body.permissionFlags !== res.locals.user.permissionFlags ) { res.status(400).send({ errors: ['User cannot change permission flags'], }); } else { next(); } } Dan karena fungsi di atas bergantung pada res.locals.user , kita dapat mengisi nilai tersebut di validateUserExists() sebelum panggilan next() :
// ... if (user) { res.locals.user = user; next(); } else { // ... Bahkan, melakukan ini di validateUserExists() akan membuatnya tidak perlu validateSameEmailBelongToSameUser() . Kami dapat menghilangkan panggilan basis data kami di sana, menggantinya dengan nilai yang dapat kami andalkan untuk di-cache di res.locals :
- const user = await userService.getUserByEmail(req.body.email); - if (user && user.id === req.params.userId) { + if (res.locals.user._id === req.params.userId) { Sekarang kami siap untuk mengintegrasikan logika izin kami ke users.routes.config.ts .
Memerlukan Izin
Pertama, kita akan mengimpor middleware dan enum baru kita :
import jwtMiddleware from '../auth/middleware/jwt.middleware'; import permissionMiddleware from '../common/middleware/common.permission.middleware'; import { PermissionFlag } from '../common/middleware/common.permissionflag.enum';Kami ingin daftar pengguna hanya dapat diakses dengan permintaan yang dibuat oleh seseorang dengan izin admin, tetapi kami masih ingin kemampuan membuat pengguna baru menjadi publik, seperti aliran harapan UX normal. Mari batasi daftar pengguna terlebih dahulu menggunakan fungsi pabrik kami sebelum pengontrol kami:
this.app .route(`/users`) .get( jwtMiddleware.validJWTNeeded, permissionMiddleware.permissionFlagRequired( PermissionFlag.ADMIN_PERMISSION ), UsersController.listUsers ) // ... Ingat bahwa panggilan pabrik di sini ( (...) ) mengembalikan fungsi middleware —maka semua middleware normal non-pabrik dirujuk tanpa pemanggilan ( () ).
Pembatasan umum lainnya adalah bahwa untuk semua rute yang menyertakan userId , kami hanya ingin pengguna atau admin yang sama memiliki akses:
.route(`/users/:userId`) - .all(UsersMiddleware.validateUserExists) + .all( + UsersMiddleware.validateUserExists, + jwtMiddleware.validJWTNeeded, + permissionMiddleware.onlySameUserOrAdminCanDoThisAction + ) .get(UsersController.getUserById) Kami juga akan mencegah pengguna meningkatkan hak istimewa mereka dengan menambahkan UsersMiddleware.userCantChangePermission , tepat sebelum referensi fungsi UsersController di akhir setiap rute PUT dan PATCH .
Tetapi mari kita asumsikan lebih lanjut bahwa logika bisnis REST API kami hanya mengizinkan pengguna dengan PAID_PERMISSION untuk memperbarui informasi mereka sama sekali. Ini mungkin atau mungkin tidak sesuai dengan kebutuhan bisnis proyek lain: Ini hanya untuk menguji perbedaan antara izin berbayar dan gratis.
Ini dapat dilakukan dengan menambahkan panggilan generator lain setelah setiap referensi userCantChangePermission yang baru saja kita tambahkan:
permissionMiddleware.permissionFlagRequired( PermissionFlag.PAID_PERMISSION ),Dengan itu, kami siap untuk memulai ulang Node.js dan mencobanya.
Pengujian Izin Manual
Untuk menguji rute, mari kita coba GET daftar pengguna tanpa token akses:
curl --include --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json'Kami menerima respons HTTP 401 karena kami perlu menggunakan JWT yang valid. Mari kita coba dengan token akses dari otentikasi kami sebelumnya:
curl --include --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS" Kali ini kami mendapatkan HTTP 403. Token kami valid, tetapi kami dilarang menggunakan titik akhir ini karena kami tidak memiliki ADMIN_PERMISSION .
Kami seharusnya tidak membutuhkannya untuk GET catatan pengguna kami sendiri, meskipun:
curl --request GET "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS"Responnya:
{ "_id": "UdgsQ0X1w", "email": "[email protected]", "permissionFlags": 1, "__v": 0 } Sebaliknya, mencoba memperbarui catatan pengguna kita sendiri akan gagal, karena nilai izin kita adalah 1 ( hanya FREE_PERMISSION ):
curl --include --request PATCH "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS" \ --data-raw '{ "firstName": "Marcos" }'Responsnya adalah 403, seperti yang diharapkan.
Sebagai latihan pembaca, saya sarankan untuk mengubah permissionFlags pengguna di database lokal dan melakukan posting baru ke /auth (untuk menghasilkan token dengan permissionFlags baru), kemudian mencoba PATCH pengguna lagi. Ingat bahwa Anda harus menyetel tanda ke nilai numerik baik PAID_PERMISSION atau ALL_PERMISSIONS , karena logika bisnis kami menetapkan bahwa ADMIN_PERMISSION dengan sendirinya tidak memungkinkan Anda menambal pengguna lain atau bahkan Anda sendiri.
Persyaratan untuk posting baru ke /auth memunculkan skenario keamanan yang perlu diingat. Saat pemilik situs mengubah izin pengguna—misalnya, untuk mencoba mengunci pengguna yang berperilaku tidak semestinya—pengguna tidak akan melihat efek ini hingga penyegaran JWT berikutnya. Itu karena pemeriksaan izin menggunakan data JWT itu sendiri untuk menghindari hit database tambahan.
Layanan seperti Auth0 dapat membantu dengan menawarkan rotasi token otomatis, tetapi pengguna masih akan mengalami perilaku aplikasi yang tidak terduga selama waktu di antara rotasi, betapapun singkatnya biasanya. Untuk mengurangi ini, pengembang harus berhati-hati untuk secara aktif mencabut token penyegaran sebagai tanggapan atas perubahan izin.
Saat mengerjakan REST API, pengembang dapat menjaga dari potensi bug dengan menjalankan kembali tumpukan perintah cURL secara berkala. Tapi itu lambat dan rawan kesalahan, dan dengan cepat menjadi membosankan.
Pengujian Otomatis
Seiring pertumbuhan API, menjadi sulit untuk mempertahankan kualitas perangkat lunak, terutama dengan logika bisnis yang sering berubah. Untuk mengurangi bug API sebanyak mungkin dan menerapkan perubahan baru dengan percaya diri, sangat umum untuk memiliki rangkaian pengujian untuk ujung depan dan/atau ujung belakang suatu aplikasi.
Daripada menyelami tes penulisan dan kode yang dapat diuji, kami akan menunjukkan beberapa mekanika dasar dan menyediakan rangkaian tes yang berfungsi untuk dikembangkan oleh pembaca.
Menangani Sisa Data Uji
Sebelum kita mengotomatisasi, ada baiknya memikirkan apa yang terjadi dengan data pengujian.
Kami menggunakan Docker Compose untuk menjalankan database lokal kami, berharap untuk menggunakan database ini untuk pengembangan, bukan sebagai sumber data produksi langsung. Pengujian yang akan kita jalankan di sini akan memengaruhi database lokal dengan meninggalkan kumpulan data pengujian baru setiap kali kita menjalankannya. Ini seharusnya tidak menjadi masalah dalam banyak kasus, tetapi jika ya, kami membiarkan pembaca melakukan perubahan docker-compose.yml untuk membuat database baru untuk tujuan pengujian.
Di dunia nyata, pengembang sering menjalankan pengujian otomatis sebagai bagian dari jalur integrasi berkelanjutan. Untuk melakukan itu, masuk akal untuk mengonfigurasi—pada level pipeline—cara membuat database sementara untuk setiap uji coba.
Kami akan menggunakan Mocha, Chai, dan SuperTest untuk membuat pengujian kami:
npm i --save-dev chai mocha supertest @types/chai @types/express @types/mocha @types/supertest ts-nodeMocha akan mengelola aplikasi kami dan menjalankan pengujian, Chai akan memungkinkan ekspresi pengujian yang lebih mudah dibaca, dan SuperTest akan memfasilitasi pengujian ujung ke ujung (E2E) dengan memanggil API kami seperti yang dilakukan klien REST.
Kami perlu memperbarui skrip kami di package.json :
"scripts": { // ... "test": "mocha -r ts-node/register 'test/**/*.test.ts' --unhandled-rejections=strict", "test-debug": "export DEBUG=* && npm test" }, Itu akan memungkinkan kita untuk menjalankan tes di folder yang akan kita buat, yang disebut test .
Sebuah Meta-tes
Untuk mencoba infrastruktur pengujian kami, mari buat file, test/app.test.ts :
import { expect } from 'chai'; describe('Index Test', function () { it('should always pass', function () { expect(true).to.equal(true); }); }); Sintaks di sini mungkin tampak tidak biasa, tetapi itu benar. Kami mendefinisikan pengujian dengan perilaku expect() ing di dalam blok it() —yang kami maksud adalah badan fungsi yang akan kami berikan ke it() —yang dipanggil dalam blok describe() .
Sekarang, di terminal, kita akan menjalankan:
npm run testKita harus melihat ini:
> mocha -r ts-node/register 'test/**/*.test.ts' --unhandled-rejections=strict Index Test ✓ should always pass 1 passing (6ms)Besar! Pustaka pengujian kami telah diinstal dan siap digunakan.
Menyederhanakan Pengujian
Untuk menjaga agar hasil pengujian tetap bersih, kami ingin menonaktifkan pencatatan permintaan Winston sepenuhnya selama pengujian berjalan normal. Itu semudah perubahan cepat ke cabang else non-debug kami di app.ts untuk mendeteksi apakah fungsi it() dari Mocha ada:
if (!process.env.DEBUG) { loggerOptions.meta = false; // when not debugging, make terse + if (typeof global.it === 'function') { + loggerOptions.level = 'http'; // for non-debug test runs, squelch entirely + } } Sentuhan terakhir yang perlu kita tambahkan adalah mengekspor app.ts kita untuk digunakan oleh pengujian kita. Di akhir app.ts , kita akan menambahkan export default sebelum server.listen() , karena listen() mengembalikan objek http.Server Node.js kita.
Dengan npm run test untuk memeriksa bahwa kami tidak merusak tumpukan, kami sekarang siap untuk menguji API kami.
Tes Otomatis REST API Nyata Pertama kami
Untuk mulai mengonfigurasi pengujian pengguna, mari buat test/users/users.test.ts , dimulai dengan impor yang diperlukan dan variabel pengujian:
import app from '../../app'; import supertest from 'supertest'; import { expect } from 'chai'; import shortid from 'shortid'; import mongoose from 'mongoose'; let firstUserIdTest = ''; // will later hold a value returned by our API const firstUserBody = { email: `marcos.henrique+${shortid.generate()}@toptal.com`, password: 'Sup3rSecret!23', }; let accessToken = ''; let refreshToken = ''; const newFirstName = 'Jose'; const newFirstName2 = 'Paulo'; const newLastName2 = 'Faraco'; Selanjutnya kita akan membuat blok describe() terluar dengan beberapa definisi penyiapan dan pembongkaran:
describe('users and auth endpoints', function () { let request: supertest.SuperAgentTest; before(function () { request = supertest.agent(app); }); after(function (done) { // shut down the Express.js server, close our MongoDB connection, then // tell Mocha we're done: app.close(() => { mongoose.connection.close(done); }); }); }); Fungsi yang kita lewati ke before() dan after() dipanggil sebelum dan sesudah semua pengujian yang akan kita definisikan dengan memanggilnya it() dalam blok describe() yang sama. Fungsi yang diteruskan ke after() mengambil panggilan balik, done , yang kami pastikan hanya dipanggil setelah kami membersihkan aplikasi dan koneksi databasenya.
Catatan: Tanpa taktik after() kami, Mocha akan hang bahkan setelah tes selesai dengan sukses. Sarannya adalah untuk selalu memanggil Mocha dengan --exit untuk menghindari --exit ini, tetapi ada peringatan (yang sering tidak disebutkan). Jika test suite akan hang karena alasan lain—seperti Promise yang salah konstruksi di test suite atau aplikasi itu sendiri—maka dengan --exit , Mocha tidak akan menunggu dan tetap akan melaporkan keberhasilan, menambahkan komplikasi halus untuk debugging.
Sekarang kita siap untuk menambahkan tes E2E individu dalam blok describe() :
it('should allow a POST to /users', async function () { const res = await request.post('/users').send(firstUserBody); expect(res.status).to.equal(201); expect(res.body).not.to.be.empty; expect(res.body).to.be.an('object'); expect(res.body.id).to.be.a('string'); firstUserIdTest = res.body.id; }); Fungsi pertama ini akan membuat pengguna baru untuk kami—yang unik, karena email pengguna kami dibuat sebelumnya menggunakan shortid . Variabel request menampung agen SuperTest, memungkinkan kami membuat permintaan HTTP ke API kami. Kami membuatnya menggunakan await , itulah sebabnya fungsi yang kami berikan ke it() harus async . Kami kemudian menggunakan expect() dari Chai untuk menguji berbagai aspek hasil.
npm run test pada titik ini akan menunjukkan pengujian baru kami berfungsi.
Sebuah Rantai Tes
Kami akan menambahkan semua blok it() berikut di dalam blok describe() kami. Kita harus menambahkannya dalam urutan yang disajikan sehingga mereka akan bekerja dengan variabel yang kita mutasi, seperti firstUserIdTest .
it('should allow a POST to /auth', async function () { const res = await request.post('/auth').send(firstUserBody); expect(res.status).to.equal(201); expect(res.body).not.to.be.empty; expect(res.body).to.be.an('object'); expect(res.body.accessToken).to.be.a('string'); accessToken = res.body.accessToken; refreshToken = res.body.refreshToken; });Di sini kami mengambil akses baru dan token penyegaran untuk pengguna kami yang baru dibuat.
it('should allow a GET from /users/:userId with an access token', async function () { const res = await request .get(`/users/${firstUserIdTest}`) .set({ Authorization: `Bearer ${accessToken}` }) .send(); expect(res.status).to.equal(200); expect(res.body).not.to.be.empty; expect(res.body).to.be.an('object'); expect(res.body._id).to.be.a('string'); expect(res.body._id).to.equal(firstUserIdTest); expect(res.body.email).to.equal(firstUserBody.email); }); Itu membuat permintaan GET yang mengandung token ke rute :userId untuk memeriksa apakah respons data pengguna cocok dengan apa yang awalnya kami kirim.
Bersarang, Melewati, Mengisolasi, dan Menepati Tes
Di Mocha, blok it() juga dapat berisi blok describe() mereka sendiri, jadi kami akan menyarangkan pengujian berikutnya di dalam blok describe() lain. Itu akan membuat kaskade dependensi kita lebih jelas dalam hasil pengujian, seperti yang akan kita tunjukkan di akhir.
describe('with a valid access token', function () { it('should allow a GET from /users', async function () { const res = await request .get(`/users`) .set({ Authorization: `Bearer ${accessToken}` }) .send(); expect(res.status).to.equal(403); }); });Pengujian yang efektif tidak hanya mencakup apa yang kita harapkan akan berhasil tetapi juga apa yang kita harapkan akan gagal. Di sini kami mencoba membuat daftar semua pengguna dan mengharapkan respons 403 karena pengguna kami (memiliki izin default) tidak diizinkan untuk menggunakan titik akhir ini.
Di dalam blok baru describe() ini, kita dapat melanjutkan menulis tes. Karena kita telah membahas fitur-fitur yang digunakan di sisa kode pengujian, itu dapat ditemukan mulai dari baris ini di repo.
Mocha menyediakan beberapa fitur yang nyaman digunakan saat mengembangkan dan men-debug tes:
- Metode
.skip()dapat digunakan untuk menghindari menjalankan satu pengujian atau seluruh blok pengujian. Ketikait()diganti denganit.skip()(juga untukdescribe()), tes atau tes yang dimaksud tidak akan dijalankan tetapi akan dihitung sebagai "tertunda" di hasil akhir Mocha. - Untuk penggunaan yang lebih sementara, fungsi
.only()menyebabkan semua pengujian yang tidak.only()-diabaikan sepenuhnya dan tidak menghasilkan apa pun yang ditandai sebagai "menunggu keputusan". - Pemanggilan
mochaseperti yang didefinisikan dalampackage.jsondapat menggunakan--bailsebagai parameter baris perintah. Ketika ini diatur, Mocha berhenti menjalankan tes segera setelah satu tes gagal. Ini sangat berguna dalam proyek contoh REST API kami, karena pengujian diatur ke kaskade; jika hanya tes pertama yang rusak, Mocha melaporkan dengan tepat, alih-alih mengeluh tentang semua tes dependen (tetapi tidak rusak) yang sekarang gagal karenanya.
Jika kami menjalankan pengujian lengkap kami pada saat ini dengan npm run test , kami akan melihat tiga pengujian yang gagal. (Jika kita akan membiarkan fungsi yang mereka andalkan tidak diterapkan untuk saat ini, ketiga tes ini akan menjadi kandidat yang baik untuk .skip() .)
Tes yang gagal bergantung pada dua bagian yang saat ini hilang dari aplikasi kami. Yang pertama ada di users.routes.config.ts :
this.app.put(`/users/:userId/permissionFlags/:permissionFlags`, [ jwtMiddleware.validJWTNeeded, permissionMiddleware.onlySameUserOrAdminCanDoThisAction, // Note: The above two pieces of middleware are needed despite // the reference to them in the .all() call, because that only covers // /users/:userId, not anything beneath it in the hierarchy permissionMiddleware.permissionFlagRequired( PermissionFlag.FREE_PERMISSION ), UsersController.updatePermissionFlags, ]); File kedua yang perlu kita perbarui adalah users.controller.ts , karena kita baru saja mereferensikan fungsi yang tidak ada di sana. Kita perlu menambahkan import { PatchUserDto } from '../dto/patch.user.dto'; di dekat bagian atas, dan fungsi yang hilang ke kelas:
async updatePermissionFlags(req: express.Request, res: express.Response) { const patchUserDto: PatchUserDto = { permissionFlags: parseInt(req.params.permissionFlags), }; log(await usersService.patchById(req.body.id, patchUserDto)); res.status(204).send(); }Menambahkan kemampuan eskalasi hak istimewa seperti itu berguna untuk pengujian tetapi tidak akan sesuai dengan sebagian besar persyaratan dunia nyata. Ada dua latihan untuk pembaca di sini:
- Pertimbangkan cara agar kode kembali melarang pengguna mengubah IzinFlag mereka sendiri sambil tetap
permissionFlagstitik akhir yang dibatasi izin untuk diuji. - Buat dan implementasikan logika bisnis (dan pengujian terkait) tentang bagaimana
permissionFlagsdapat diubah melalui API. (Ada teka-teki ayam-dan-telur di sini: Bagaimana cara pengguna tertentu mendapatkan izin untuk mengubah izin?)
Dengan itu, npm run test sekarang harus berhasil diselesaikan dengan output yang diformat dengan baik seperti ini:
Index Test ✓ should always pass users and auth endpoints ✓ should allow a POST to /users (76ms) ✓ should allow a POST to /auth ✓ should allow a GET from /users/:userId with an access token with a valid access token ✓ should allow a GET from /users ✓ should disallow a PATCH to /users/:userId ✓ should disallow a PUT to /users/:userId with an nonexistent ID ✓ should disallow a PUT to /users/:userId trying to change the permission flags ✓ should allow a PUT to /users/:userId/permissionFlags/2 for testing with a new permission level ✓ should allow a POST to /auth/refresh-token ✓ should allow a PUT to /users/:userId to change first and last names ✓ should allow a GET from /users/:userId and should have a new full name ✓ should allow a DELETE from /users/:userId 13 passing (231ms)Kami sekarang memiliki cara untuk memverifikasi dengan cepat REST API kami berfungsi seperti yang diharapkan.
Debugging (Dengan) Tes
Pengembang yang menghadapi kegagalan pengujian yang tidak terduga dapat dengan mudah memanfaatkan modul debug Winston dan Node.js saat menjalankan rangkaian pengujian.
Misalnya, mudah untuk fokus pada kueri Mongoose mana yang dieksekusi dengan menjalankan DEBUG=mquery npm run test . (Perhatikan bagaimana perintah itu tidak memiliki awalan export dan && di tengah, yang akan membuat lingkungan tetap ada untuk perintah selanjutnya.)
Anda juga dapat menampilkan semua keluaran debug dengan npm run test-debug , berkat penambahan kami sebelumnya ke package.json .
Dengan itu, kami memiliki REST API yang berfungsi, skalabel, dan didukung MongoDB, dengan rangkaian pengujian otomatis yang nyaman. Tapi itu masih kehilangan beberapa hal penting.
Keamanan (Semua Proyek Harus Memakai Helm)
Saat bekerja dengan Express.js, dokumentasi harus dibaca, terutama praktik terbaik keamanannya. Setidaknya, ada baiknya mengejar:
- Mengonfigurasi dukungan TLS
- Menambahkan middleware pembatas kecepatan
- Memastikan dependensi npm aman (pembaca mungkin ingin memulai dengan
npm auditatau masuk lebih dalam dengan snyk) - Menggunakan perpustakaan Helm untuk membantu melindungi dari kerentanan keamanan umum
Poin terakhir ini mudah untuk ditambahkan ke proyek contoh kami:
npm i --save helmet Kemudian, di app.ts , kita hanya perlu mengimpornya dan menambahkan panggilan app.use() lain:
import helmet from 'helmet'; // ... app.use(helmet());Seperti yang ditunjukkan oleh dokumennya, Helm (seperti tambahan keamanan lainnya) bukanlah peluru perak, tetapi setiap pencegahan memang membantu.
Berisi Proyek API REST Kami Dengan Docker
Dalam seri ini, kami tidak membahas container Docker secara mendalam, tetapi kami menggunakan MongoDB dalam container dengan Docker Compose. Pembaca yang tidak terbiasa dengan Docker tetapi ingin mencoba langkah selanjutnya dapat membuat file bernama Dockerfile (tanpa ekstensi) di root proyek:
FROM node:14-slim RUN mkdir -p /usr/src/app WORKDIR /usr/src/app COPY . . RUN npm install EXPOSE 3000 CMD ["node", "./dist/app.js"] Konfigurasi ini dimulai dengan gambar resmi node:14-slim dari Docker, dan membangun serta menjalankan contoh REST API kami dalam sebuah wadah. Konfigurasi dapat berubah dari kasus ke kasus, tetapi default yang tampak umum ini berfungsi untuk proyek kami.
Untuk membangun gambar, kami hanya menjalankan ini di root proyek (mengganti tag_your_image_here seperti yang diinginkan):
docker build . -t tag_your_image_hereKemudian, salah satu cara untuk menjalankan back end kita—dengan asumsi penggantian teks yang sama persis—adalah:
docker run -p 3000:3000 tag_your_image_here Pada titik ini, MongoDB dan Node.js dapat menggunakan Docker, tetapi kita harus memulainya dengan dua cara yang berbeda. Kami membiarkannya sebagai latihan bagi pembaca untuk menambahkan aplikasi Node.js utama ke docker-compose.yml sehingga seluruh aplikasi dapat diluncurkan dengan satu perintah docker-compose .
Keterampilan REST API Lebih Lanjut untuk Dijelajahi
Dalam artikel ini, kami melakukan peningkatan ekstensif pada REST API kami: Kami menambahkan MongoDB dalam container, mengonfigurasi Mongoose dan express-validator, menambahkan otentikasi berbasis JWT dan sistem izin yang fleksibel, dan menulis serangkaian pengujian otomatis.
Ini adalah titik awal yang solid untuk pengembang back-end baru dan lanjutan. Namun dalam beberapa hal, proyek kami mungkin tidak ideal untuk penggunaan produksi, penskalaan, dan pemeliharaan. Selain latihan pembaca yang kami taburkan di seluruh artikel ini, apa lagi yang bisa dipelajari?
Pada level API, kami menyarankan Anda membaca tentang membuat spesifikasi yang sesuai dengan OpenAPI. Pembaca yang tertarik untuk mengejar pengembangan perusahaan juga ingin mencoba NestJS. Ini adalah kerangka kerja lain yang dibangun di atas Express.js, tetapi lebih kuat dan abstrak—itulah mengapa ada baiknya menggunakan proyek contoh kami untuk membiasakan diri dengan dasar-dasar Express.js terlebih dahulu. Tidak kalah pentingnya, pendekatan GraphQL ke API memiliki daya tarik yang luas sebagai alternatif REST.
Dalam hal izin, kami membahas pendekatan flag bitwise dengan generator middleware untuk flag yang ditentukan secara manual. Untuk kenyamanan lebih lanjut saat penskalaan, ada baiknya melihat ke perpustakaan CASL, yang terintegrasi dengan Mongoose. Ini memperluas fleksibilitas pendekatan kami, memungkinkan definisi singkat tentang kemampuan yang harus diizinkan oleh flag tertentu, seperti can(['update', 'delete'], '(model name here)', { creator: 'me' }); menggantikan seluruh fungsi middleware khusus.
Kami telah menyediakan batu loncatan pengujian otomatis praktis dalam proyek ini, tetapi beberapa topik penting berada di luar jangkauan kami. Kami menyarankan agar pembaca:
- Jelajahi pengujian unit untuk menguji komponen secara terpisah—Mocha dan Chai juga dapat digunakan untuk ini.
- Lihat alat cakupan kode, yang membantu mengidentifikasi kesenjangan dalam rangkaian pengujian dengan menunjukkan baris kode yang tidak dijalankan selama pengujian. Dengan alat seperti itu, pembaca kemudian dapat melengkapi pengujian contoh, sesuai kebutuhan—tetapi mereka mungkin tidak mengungkapkan setiap skenario yang hilang, seperti apakah pengguna dapat mengubah izin mereka melalui
PATCHke/users/:userId. - Coba pendekatan lain untuk pengujian otomatis. Kami telah menggunakan antarmuka ekspektasi gaya
expect-driven development (BDD) dari Chai, tetapi juga mendukungshould()danassert. Anda juga perlu mempelajari pustaka pengujian lainnya, seperti Jest.
Selain topik ini, API REST Node.js/TypeScript kami siap untuk dibangun. Khususnya, pembaca mungkin ingin menerapkan lebih banyak middleware untuk menegakkan logika bisnis umum di sekitar sumber daya pengguna standar. Saya tidak akan membahasnya lebih dalam di sini, tetapi saya akan dengan senang hati memberikan panduan dan tips kepada pembaca yang mendapati diri mereka diblokir—tinggalkan komentar di bawah.
Kode lengkap untuk proyek ini tersedia sebagai repo GitHub sumber terbuka.
Bacaan Lebih Lanjut di Blog Teknik Toptal:
- Menggunakan Rute Express.js untuk Penanganan Kesalahan Berbasis Janji
