Node.js/TypeScript REST API Oluşturma, Bölüm 3: MongoDB, Kimlik Doğrulama ve Otomatik Testler
Yayınlanan: 2022-03-11Express.js ve TypeScript ile bir Node.js REST API'sinin nasıl oluşturulacağına ilişkin serimizin bu noktasında, çalışan bir arka uç oluşturduk ve kodumuzu rota yapılandırması, hizmetler, ara katman yazılımı, denetleyiciler ve modellere ayırdık. Oradan devam etmeye hazırsanız, örnek depoyu kopyalayın ve git checkout toptal-article-02 çalıştırın.
Mongoose, Kimlik Doğrulama ve Otomatik Test ile REST API
Bu üçüncü ve son makalede, aşağıdakileri ekleyerek REST API'mizi geliştirmeye devam edeceğiz:
- Mongoose , MongoDB ile çalışmamıza ve bellek içi DAO'muzu gerçek bir veritabanıyla değiştirmemize izin verecek.
- API tüketicilerinin uç noktalarımıza güvenli bir şekilde erişmek için bir JSON Web Simgesi (JWT) kullanabilmesi için kimlik doğrulama ve izin yetenekleri.
- Mocha (bir test çerçevesi), Chai (bir onaylama kitaplığı) ve SuperTest (bir HTTP soyutlama modülü) kullanılarak kod tabanı büyüdükçe ve değiştikçe gerilemeleri kontrol etmeye yardımcı olan otomatik test .
Bu arada, doğrulama ve güvenlik kitaplıkları ekleyeceğiz, Docker ile biraz deneyim kazanacağız ve okuyucuların kendi REST API'lerini oluştururken ve genişletirken keşfetmek için iyi yapacakları birkaç konu, kitaplık ve beceri önereceğiz.
MongoDB'yi Konteyner Olarak Kurmak
Bir önceki makaledeki bellek içi veritabanımızı gerçek bir veritabanıyla değiştirerek başlayalım.
Geliştirme için yerel bir veritabanı oluşturmak için MongoDB'yi yerel olarak kurabiliriz. Ancak ortamlar arasındaki farklılıklar (örneğin işletim sistemi dağıtımları ve sürümleri) sorunlara neden olabilir. Bunu önlemek için, endüstri standardı bir araçtan yararlanmak için bu fırsatı kullanacağız: Docker kapsayıcısı.
Okuyucuların yapması gereken tek şey Docker'ı kurmak ve ardından Docker Compose'u kurmaktır. Kurulduktan sonra, bir terminalde docker -v çalıştırmak bir Docker sürüm numarası vermelidir.
Şimdi, MongoDB'yi çalıştırmak için projemizin kökünde aşağıdakileri içeren docker-compose.yml adlı bir YAML dosyası oluşturacağız:
version: '3' services: mongo: image: mongo volumes: - ./data:/data/db ports: - "27017:27017"Docker Compose, tek bir yapılandırma dosyasıyla aynı anda birkaç kapsayıcı çalıştırmamıza olanak tanır. Bu makalenin sonunda, Docker'da REST API arka ucunu çalıştırmaya bakacağız, ancak şimdilik, onu yerel olarak kurmak zorunda kalmadan MongoDB'yi çalıştırmak için kullanacağız:
sudo docker-compose up -d up komutu, 27017'nin standart MongoDB bağlantı noktasını dinleyerek tanımlanan kapsayıcıyı başlatır. -d anahtarı, komutu terminalden ayırır. Her şey sorunsuz çalışırsa, şöyle bir mesaj görmeliyiz:
Creating network "toptal-rest-series_default" with the default driver Creating toptal-rest-series_mongo_1 ... done Ayrıca proje kökünde yeni bir data dizini oluşturacaktır, bu yüzden .gitignore bir data satırı eklemeliyiz.
Şimdi, MongoDB Docker kapsayıcımızı kapatmamız gerekirse, sadece sudo docker-compose down çalıştırmamız gerekiyor ve aşağıdaki çıktıyı görmeliyiz:
Stopping toptal-rest-series_mongo_1 ... done Removing toptal-rest-series_mongo_1 ... done Removing network toptal-rest-series_default Node.js/MongoDB REST API arka uçumuzu başlatmak için bilmemiz gereken tek şey bu. MongoDB'nin uygulamamızın kullanımına hazır olması için sudo docker-compose up -d kullandığımızdan emin olalım.
MongoDB'ye Erişmek için Mongoose Kullanma
MongoDB ile iletişim kurmak için arka ucumuz Mongoose adlı bir nesne veri modelleme (ODM) kitaplığından yararlanacaktır. Mongoose'un kullanımı oldukça kolay olsa da, gerçek dünya projeleri için sunduğu tüm gelişmiş olasılıkları öğrenmek için belgelere göz atmaya değer.
Mongoose'u kurmak için aşağıdakileri kullanıyoruz:
npm i mongoose MongoDB örneğimizle olan bağlantıyı yönetmek için bir Mongoose hizmeti yapılandıralım. Bu hizmet birden fazla kaynak arasında paylaşılabildiğinden, onu projemizin common klasörüne ekleyeceğiz.
Yapılandırma basittir. Kesinlikle gerekli olmasa da, aşağıdaki Mongoose bağlantı seçeneklerini özelleştirmek için bir mongooseOptions nesnemiz olacak:
-
useNewUrlParser: Butrueolarak ayarlanmadan, Mongoose bir kullanımdan kaldırma uyarısı yazdırır. -
useUnifiedTopology: Mongoose belgeleri, daha yeni bir bağlantı yönetimi motoru kullanmak için bununtrueolarak ayarlanmasını önerir. -
serverSelectionTimeoutMS: Bu demo projesinin UX'i amacıyla, varsayılan 30 saniyeden daha kısa bir süre, MongoDB'yi Node.js'den önce başlatmayı unutan herhangi bir okuyucunun, görünüşte yanıt vermeyen bir arka uç yerine, bu konuda yararlı geri bildirimleri daha erken göreceği anlamına gelir. . -
useFindAndModify: Bunufalseolarak ayarlamak ayrıca bir kullanımdan kaldırma uyarısını da önler, ancak Mongoose bağlantı seçenekleri arasında değil, belgelerin kullanımdan kaldırma bölümünde bahsedilmiştir. Daha spesifik olarak, bu, Mongoose'un daha eski bir Mongoose şimi yerine daha yeni bir yerel MongoDB özelliği kullanmasına neden olur.
Bu seçenekleri bir miktar başlatma ve yeniden deneme mantığıyla birleştirerek, işte son common/services/mongoose.service.ts dosyası:
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(); Mongoose'un connect() işlevi ile kendi connectWithRetry() hizmet işlevimiz arasındaki farkı düz tuttuğunuzdan emin olun:
-
mongoose.connect()yerel MongoDB hizmetimize bağlanmaya çalışır (docker-composeile çalışır) veserverSelectionTimeoutMSmilisaniye sonra zaman aşımına uğrar. -
MongooseService.connectWithRetry(), uygulamamızın başlaması ancak MongoDB hizmetinin henüz çalışmaması durumunda yukarıdakileri yeniden dener. Tek bir kurucuda olduğu için,connectWithRetry()yalnızca bir kez çalıştırılacak, ancakconnect()çağrısını süresiz olarak yeniden deneyecek ve bir zaman aşımı meydana geldiğinderetrySecondssaniyelik bir duraklama ile.
Bir sonraki adımımız, önceki bellek içi veritabanımızı MongoDB ile değiştirmek!
Bellek İçi Veritabanımızı Kaldırma ve MongoDB Ekleme
Önceden, oluşturmakta olduğumuz diğer modüllere odaklanmamıza izin vermek için bir bellek içi veritabanı kullanıyorduk. Bunun yerine Mongoose'u kullanmak için, users.dao.ts tamamen yeniden düzenlememiz gerekecek. Başlamak için bir import ifadesine daha ihtiyacımız olacak:
import mongooseService from '../../common/services/mongoose.service'; Şimdi, yapıcı dışındaki her şeyi UsersDao sınıfı tanımından kaldıralım. Yapıcıdan önce Mongoose için Kullanıcı Schema oluşturarak onu tekrar doldurmaya başlayabiliriz:
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); Bu, MongoDB koleksiyonumuzu tanımlar ve bellek içi veritabanımızın sahip olmadığı özel bir özelliği ekler: password alanındaki select: false , bir kullanıcı aldığımızda veya tüm kullanıcıları listelediğimizde bu alanı gizleyecektir.
Kullanıcı şemamız muhtemelen tanıdık geliyor çünkü DTO varlıklarımıza benziyor. Temel fark, DTO varlıkları bir HTTP isteğinde hangi alanların kabul edileceğini tanımlarken, Users adlı MongoDB koleksiyonumuzda hangi alanların bulunması gerektiğini tanımlamamızdır.
Yaklaşımımızın bu kısmı değişmiyor, dolayısıyla user.dao.ts'nin en üstünde üç users.dao.ts içe aktarmaya devam ediyor. Fakat CRUD method işlemlerimizi uygulamadan önce DTO'larımızı iki şekilde güncelleyeceğiz.
DTO Değişiklik No. 1: id ve _id
Mongoose otomatik olarak bir _id alanı sağladığından, id alanını DTO'lardan kaldıracağız. Yine de rota isteğindeki parametrelerden gelecektir.
Mongoose modellerinin varsayılan olarak bir sanal id alıcısı sağladığına dikkat edin, bu nedenle karışıklığı önlemek için yukarıdaki seçeneği { id: false } ile devre dışı bıraktık. Ancak bu, kullanıcı ara katman yazılımımız validateSameEmailBelongToSameUser() içindeki user.id referansımızı bozdu — bunun yerine user._id ihtiyacımız var.
Bazı veritabanları kural id kullanır ve diğerleri _id kullanır, bu nedenle mükemmel bir arabirim yoktur. Mongoose kullanan örnek projemiz için, kodun hangi noktasında hangisini kullandığımıza dikkat ettik, ancak uyumsuzluk yine de API tüketicilerine maruz kalacak:
Bunu, projenin sonunda mevcut olan birçok gerçek dünya çözümünden birini uygulamak için okuyuculara bir alıştırma olarak bırakıyoruz.
DTO Değişiklik No. 2: Bayraklara Dayalı İzinler için Hazırlık
Ayrıca, uygulayacağımız daha karmaşık permissionLevel sisteminin yanı sıra yukarıdaki Mongoose userSchema tanımını yansıtmak için permissionFlags allowLevel öğesini allowFlags olarak yeniden adlandıracağız.
DTO'lar: DRY Prensibi Nedir?
DTO'nun yalnızca API istemcisi ile veritabanımız arasında iletmek istediğimiz alanları içerdiğini unutmayın. Bu talihsiz görünebilir çünkü model ve DTO'lar arasında bir miktar örtüşme vardır, ancak "varsayılan olarak güvenlik" pahasına DRY için çok fazla zorlamaya dikkat edin. Bir alan eklemek, yalnızca bir yere eklenmesini gerektiriyorsa, geliştiriciler, yalnızca dahili olması gerektiği halde farkında olmadan onu API'de gösterebilir. Bunun nedeni, sürecin onları veri depolama ve veri aktarımı hakkında potansiyel olarak farklı iki gereksinim grubuyla iki ayrı bağlam olarak düşünmeye zorlamamasıdır.
DTO değişikliklerimiz yapıldıktan sonra, create ile başlayarak CRUD method işlemlerimizi ( UsersDao yapıcısından sonra) uygulayabiliriz:
async addUser(userFields: CreateUserDto) { const userId = shortid.generate(); const user = new this.User({ _id: userId, ...userFields, permissionFlags: 1, }); await user.save(); return userId; } API tüketicisinin permissionFlags aracılığıyla userFields için gönderdiği her şeyi 1 değeriyle geçersiz kıldığımızı unutmayın.
Daha sonra, bir kullanıcıyı kimliğe göre alma, bir kullanıcıyı e-posta ile alma ve sayfa numaralandırmaya sahip kullanıcıları listeleme temel işlevlerini okuduk :
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(); } Bir kullanıcıyı güncellemek için tek bir DAO işlevi yeterli olacaktır çünkü temel alınan Mongoose findOneAndUpdate() işlevi belgenin tamamını veya yalnızca bir kısmını güncelleyebilir. Kendi PutUserDto , TypeScript birleşim türü ( | ile gösterilir) kullanarak, bir PatchUserDto veya bir userFields olarak userFields alacağını unutmayın:
async updateUserById( userId: string, userFields: PatchUserDto | PutUserDto ) { const existingUser = await this.User.findOneAndUpdate( { _id: userId }, { $set: userFields }, { new: true } ).exec(); return existingUser; } new: true seçeneği, Mongoose'a nesneyi başlangıçta olduğu gibi değil, güncellemeden sonra olduğu gibi döndürmesini söyler.
Sil , Mongoose ile özlüdür:
async removeUserById(userId: string) { return this.User.deleteOne({ _id: userId }).exec(); } Okuyucular, User üye işlevlerine yapılan çağrıların her birinin bir exec() çağrısına zincirlendiğini fark edebilir. Bu isteğe bağlıdır, ancak Mongoose geliştiricileri, hata ayıklama sırasında daha iyi yığın izleri sağladığı için bunu önerir.
DAO'muzu kodladıktan sonra, yeni işlevlere uyması için son makalemizdeki users.service.ts biraz güncellememiz gerekiyor. Büyük yeniden düzenlemeye gerek yok, sadece üç rötuş:
@@ -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); } Çoğu işlev çağrısı tamamen aynı kalır, çünkü UsersDao yeniden düzenlediğimizde, önceki makalede oluşturduğumuz yapıyı koruduk. Ama neden istisnalar?
- Yukarıda belirttiğimiz gibi hem
PUThem dePATCHiçinupdateUserById()kullanıyoruz. (Bölüm 2'de bahsedildiği gibi, mektuba belirli RFC'lere bağlı kalmaya çalışmak yerine tipik REST API uygulamalarını takip ediyoruz. Diğer şeylerin yanı sıra bu,PUTisteklerinin mevcut değilse yeni varlıklar oluşturmaması anlamına gelir; bu şekilde, arka ucumuz, kimlik oluşturma kontrolünü API tüketicilerine devretmez.) - Yeni DAO uygulamamız bunları kullanacağından,
limitvepageparametrelerinigetUsers()'a aktarıyoruz.
Buradaki ana yapı oldukça sağlam bir kalıptır. Örneğin, geliştiriciler Mongoose ve MongoDB'yi TypeORM ve PostgreSQL gibi bir şeyle değiştirmek isterse yeniden kullanılabilir. Yukarıdaki gibi, böyle bir değiştirme, DAO'nun bireysel işlevlerini yeniden düzenlemeyi ve imzalarını kodun geri kalanıyla eşleşecek şekilde korumayı gerektirir.
Mongoose destekli REST API'mizi Test Etme
API arka ucunu npm start ile başlatalım. Daha sonra bir kullanıcı oluşturmayı deneyeceğiz:
curl --request POST 'localhost:3000/users' \ --header 'Content-Type: application/json' \ --data-raw '{ "password":"secr3tPass!23", "email":"[email protected]" }'Yanıt nesnesi yeni bir kullanıcı kimliği içerir:
{ "id": "7WYQoVZ3E" }Önceki makalede olduğu gibi, kalan manuel testler ortam değişkenlerini kullanarak daha kolay olacaktır:
REST_API_EXAMPLE_Kullanıcıyı güncellemek şöyle görünür:
curl --include --request PATCH "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "firstName": "Marcos", "lastName": "Silva" }' Yanıt HTTP/1.1 204 No Content ile başlamalıdır. ( --include anahtarı olmadan, uygulamamıza uygun olarak hiçbir yanıt yazdırılmaz.)
Şimdi kullanıcıya yukarıdaki güncellemeleri kontrol ettirirsek…:
curl --request GET "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "firstName": "Marcos", "lastName": "Silva" }' … yanıt, yukarıda tartışılan _id alanı da dahil olmak üzere beklenen alanları gösterir:
{ "_id": "7WYQoVZ3E", "email": "[email protected]", "permissionFlags": 1, "__v": 0, "firstName": "Marcos", "lastName": "Silva" } Ayrıca, sürüm oluşturma için Mongoose tarafından kullanılan özel bir alan olan __v vardır; bu kayıt her güncellendiğinde artırılacaktır.
Ardından, kullanıcıları listeleyelim:
curl --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json' Beklenen yanıt aynı, sadece [] içine sarılmış.
Artık şifremiz güvenli bir şekilde saklandığına göre, kullanıcıyı kaldırabileceğimizden emin olalım:
curl --include --request DELETE "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json'Yine 204 yanıtı bekliyoruz.
Okuyucular, Mongoose Schema tanımındaki select: false , onu amaçlandığı gibi GET çıktımızdan sakladığından, parola alanının düzgün çalışıp çalışmadığını merak edebilir. Tekrar bir kullanıcı oluşturmak için ilk POST tekrarlayalım, sonra kontrol edelim. (Yeni kimliği daha sonrası için saklamayı unutmayın.)
MongoDB Konteynerleri ile Gizli Parolalar ve Doğrudan Veri Hata Ayıklama
Parolaların güvenli bir şekilde saklandığını kontrol etmek için (yani düz metin yerine karma), geliştiriciler MongoDB verilerini doğrudan inceleyebilir. Bunun bir yolu, standart mongo CLI istemcisine çalışan Docker kapsayıcısının içinden erişmektir:
sudo docker exec -it toptal-rest-series_mongo_1 mongo Buradan, use api-db ve ardından db.users.find().pretty() , parolalar dahil tüm kullanıcı verileri listelenir.
GUI tercih edenler, Robo 3T gibi ayrı bir MongoDB istemcisi kurabilirler:
Parola öneki ( $argon2... ) PHC dize biçiminin bir parçasıdır ve kasıtlı olarak değiştirilmeden saklanır: Argon2 ve genel parametrelerinin belirtilmesi, bir bilgisayar korsanının, parolayı çalmayı başarırsa orijinal parolaları belirlemesine yardımcı olmaz. veri tabanı. Saklanan parola, aşağıda JWT'lerle kullanacağımız bir teknik olan tuzlama kullanılarak daha da güçlendirilebilir. Yukarıda salting uygulamasını okuyucuya bir alıştırma olarak bırakıyoruz ve iki kullanıcı aynı şifreyi girdiğinde saklanan değerler arasındaki farkı inceliyoruz.
Artık Mongoose'un MongoDB veritabanımıza başarıyla veri gönderdiğini biliyoruz. Ancak API tüketicilerimizin isteklerinde uygun verileri kullanıcı rotalarımıza göndereceğini nasıl bilebiliriz?
Ekspres doğrulayıcı ekleme
Alan doğrulamasını gerçekleştirmenin birkaç yolu vardır. Bu makalede, oldukça kararlı, kullanımı kolay ve düzgün bir şekilde belgelenmiş ekspres doğrulayıcı kullanacağız. Mongoose ile gelen doğrulama işlevini kullanabilirken, ekspres doğrulayıcı ekstra özellikler sağlar. Örneğin, Mongoose'da özel bir doğrulayıcı kodlamamızı gerektiren e-posta adresleri için kullanıma hazır bir doğrulayıcı ile birlikte gelir.
Hadi yükleyelim:
npm i express-validator Doğrulamak istediğimiz alanları ayarlamak için, users.routes.config.ts adresinde içe aktaracağımız body() yöntemini kullanacağız. body() yöntemi, alanları doğrulayacak ve arıza durumunda express.Request nesnesinde saklanan bir hata listesi oluşturacaktır.
Daha sonra hata listesini kontrol etmek ve kullanmak için kendi ara katman yazılımımıza ihtiyacımız var. Bu mantığın farklı rotalar için aynı şekilde çalışması muhtemel olduğundan, common/middleware/body.validation.middleware.ts aşağıdakilerle oluşturalım:
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(); Bununla, body() işlevinden kaynaklanan herhangi bir hatayı işlemeye hazırız. Aşağıdakileri users.routes.config.ts içine ekleyelim:
import BodyValidationMiddleware from '../common/middleware/body.validation.middleware'; import { body } from 'express-validator';Artık rotalarımızı aşağıdakilerle güncelleyebiliriz:
@@ -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, BodyValidationMiddleware.verifyBodyFieldsErrors , mevcut olan herhangi bir body() satırından sonra her rotaya eklediğinizden emin olun, aksi takdirde hiçbirinin bir etkisi olmaz.
POST ve PUT , yerel olarak oluşturulmuş validateRequiredUserBodyFields işlevimiz yerine ekspres doğrulayıcıyı kullanacak şekilde nasıl güncellediğimize dikkat edin. Bu işlevi kullananlar yalnızca bu yollar olduğundan, uygulaması users.middleware.ts silinebilir.
Bu kadar! Okuyucular, Node.js'yi yeniden başlatabilir ve çeşitli girdileri nasıl işlediğini görmek için favori REST istemcilerini kullanarak sonucu deneyebilir. Daha fazla olasılık için ekspres doğrulayıcı belgelerini keşfetmeyi unutmayın; Örneğimiz, istek doğrulama için yalnızca bir başlangıç noktasıdır.
Geçerli veriler , sağlanması gereken bir husustur; geçerli kullanıcılar ve eylemler başkadır.
Kimlik Doğrulama ve İzinler (veya "Yetkilendirme") Akışı
Node.js uygulamamız, eksiksiz bir users/ uç nokta kümesi sunarak API tüketicilerinin kullanıcıları oluşturmasına, güncellemesine ve listelemesine olanak tanır. Ancak her uç nokta, sınırsız genel erişime izin verir. Kullanıcıların birbirlerinin verilerini değiştirmesini ve dışarıdan gelenlerin herkese açık olmasını istemediğimiz herhangi bir uç noktaya erişmesini önlemek yaygın bir kalıptır.
Bu kısıtlamalarla ilgili iki temel yön vardır ve her ikisi de "auth" olarak kısaltılır. Kimlik doğrulama , isteğin kimden geldiği ve yetkilendirme , isteklerini yapmalarına izin verilip verilmediği ile ilgilidir. Hangisinin tartışıldığının farkında olmak önemlidir. Kısa formlar olmasa bile, standart HTTP yanıt kodları sorunu karıştırmayı başarır: 401 Unauthorized , kimlik doğrulama ile ve 403 Forbidden , yetkilendirme ile ilgilidir. Modül adlarında "authentication" anlamına gelen "auth" tarafında hata yapacağız ve yetkilendirme konuları için "izinleri" kullanacağız.
Kısa formlar olmasa bile, standart HTTP yanıt kodları sorunu karıştırmayı başarır:
401 Unauthorized, kimlik doğrulama ile ve403 Forbidden, yetkilendirme ile ilgilidir.
Auth0 gibi açılan üçüncü taraf kimlik sağlayıcıları da dahil olmak üzere keşfedilecek çok sayıda kimlik doğrulama yaklaşımı vardır. Bu makalede, basit ama ölçeklenebilir bir uygulama seçtik. JWT'lere dayanmaktadır.
Bir JWT, bizim durumumuzda kullanıcının e-posta adresini ve izin işaretlerini içeren, kimlik doğrulamayla ilgili olmayan bazı meta verilerle şifrelenmiş JSON'dan oluşur. JSON, meta verilerin bütünlüğünü doğrulamak için bir sır da içerecektir.
Buradaki fikir, müşterilerin herkese açık olmayan her istek içinde geçerli bir JWT göndermesini istemektir. Bu, müşterinin son zamanlarda kullanmak istediği uç nokta için geçerli kimlik bilgilerine sahip olduğunu, kimlik bilgilerini her istekte kablo üzerinden göndermek zorunda kalmadan doğrulamamızı sağlar.
Ancak bu, örnek API kod tabanımıza nereye sığacak? Kolay: Ara katman yazılımı ile rota yapılandırmamızda kullanabiliriz!
Kimlik Doğrulama Modülünü Ekleme
Önce JWT'lerimizde ne olacağını yapılandıralım. Burada, kullanıcı kaynağımızdaki permissionFlags alanını kullanmaya başlayacağız, ancak yalnızca JWT'ler içinde şifrelemek için uygun meta veriler olduğu için - JWT'lerin doğal olarak ince taneli izinler mantığıyla ilgisi olduğu için değil.
JWT üreten ara katman yazılımı oluşturmadan önce, Mongoose'u normalde onu almaktan kaçınacak şekilde ayarladığımız için, users.dao.ts parola alanını almak için özel bir işlev eklememiz gerekecek:
async getUserByEmailWithPassword(email: string) { return this.User.findOne({ email: email }) .select('_id email permissionFlags +password') .exec(); } Ve users.service.ts içinde:
async getUserByEmailWithPassword(email: string) { return UsersDao.getUserByEmailWithPassword(email); } Şimdi proje kökümüzde bir auth klasörü oluşturalım - API tüketicilerinin JWT'ler oluşturmasına izin vermek için bir uç nokta ekleyeceğiz. İlk olarak, onun için auth/middleware/auth.middleware.ts , AuthMiddleware adında bir singleton olarak bir ara katman yazılımı oluşturalım.
Bazı import s'lere ihtiyacımız olacak:
import express from 'express'; import usersService from '../../users/services/users.service'; import * as argon2 from 'argon2'; AuthMiddleware sınıfında, bir API kullanıcısının isteklerine geçerli oturum açma kimlik bilgilerini ekleyip eklemediğini kontrol etmek için bir ara yazılım işlevi oluşturacağız:
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'] }); } req.body içinde email ve password var olduğundan emin olmak için ara katman yazılımına gelince, daha sonra yolu yukarıdaki verifyUserPassword() işlevini kullanacak şekilde yapılandırdığımızda ekspres-doğrulayıcı kullanacağız.
JWT Sırlarını Saklama
Bir JWT oluşturmak için, oluşturulan JWT'lerimizi imzalamak ve ayrıca müşteri isteklerinden gelen JWT'leri doğrulamak için kullanacağımız bir JWT sırrına ihtiyacımız olacak. JWT sırrının değerini bir TypeScript dosyası içinde sabit kodlamak yerine, onu asla bir kod deposuna gönderilmemesi gereken ayrı bir "ortam değişkeni" dosyasında .env .
Genel uygulama olarak, geliştiricilerin gerçek .env oluştururken hangi değişkenlerin gerekli olduğunu anlamalarına yardımcı olmak için depoya bir .env.example dosyası ekledik. Bizim durumumuzda, JWT sırrımızı bir dizge olarak saklayan JWT_SECRET adlı bir değişken istiyoruz. Bu makalenin sonuna kadar bekleyen ve repo'nun son dalını kullanan okuyucular, bu değerleri yerel olarak değiştirmeyi unutmamalıdır .
Gerçek dünya projelerinin özellikle JWT sırlarını ortama göre (geliştirme, evreleme, üretim vb.) farklılaştırarak JWT'nin en iyi uygulamalarını takip etmesi gerekecektir.
.env dosyamız (projenin kökünde) aşağıdaki formatı kullanmalı ancak aynı gizli değeri tutmamalıdır:
JWT_SECRET=My!@!Se3cr8tH4sh3Bu değişkenleri uygulamamıza yüklemenin kolay bir yolu, dotenv adlı bir kitaplık kullanmaktır:
npm i dotenv Gereken tek yapılandırma, uygulamamızı başlattığımız anda dotenv.config() işlevini çağırmaktır. app.ts en üstüne şunu ekleyeceğiz:
import dotenv from 'dotenv'; const dotenvResult = dotenv.config(); if (dotenvResult.error) { throw dotenvResult.error; }Kimlik Doğrulama Denetleyicisi
Son JWT oluşturma ön koşulu, jsonwebtoken kitaplığını ve TypeScript türlerini yüklemektir:
npm i jsonwebtoken npm i --save-dev @types/jsonwebtoken Şimdi, auth/controllers/auth.controller.ts adresinde /auth controller oluşturalım. dotenv kitaplığını buraya aktarmamız gerekmiyor çünkü onu app.ts içe aktarmak, .env dosyasının içeriğini process adı verilen Node.js global nesnesi aracılığıyla uygulama genelinde kullanılabilir hale getiriyor:
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(); jsonwebtoken kitaplığı, jwtSecret ile yeni bir belirteç imzalayacaktır. Ayrıca Node.js-native crypto modülünü kullanarak bir tuz ve bir karma oluşturacağız, ardından bunları API tüketicilerinin mevcut JWT'yi yenileyebileceği bir refreshToken oluşturmak için kullanacağız. ölçekleyebilmek.
refreshKey , refreshToken ve accessToken arasındaki fark nedir? *Token Token'lar, refreshToken genel halka açık olanın ötesindeki herhangi bir istek için kullanıldığı ve yenilemeToken'ın süresi dolmuş bir accessToken için bir değiştirme talebinde bulunmak için kullanıldığı fikriyle API tüketicilerimize accessToken . refreshKey ise, refreshToken içinde şifrelenmiş salt değişkenini, aşağıda ele alacağımız yenileme ara katman yazılımımıza geri geçirmek için kullanılır.
Uygulamamızın bizim için jsonwebtoken tanıtıcı belirteci süresinin dolmasına sahip olduğunu unutmayın. JWT'nin süresi dolmuşsa, istemcinin bir kez daha kimlik doğrulaması yapması gerekir.
İlk Node.js REST API Kimlik Doğrulama Yolu
Şimdi uç auth/auth.routes.config.ts adresinde yapılandıralım:
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; } } Ve app.ts dosyamıza eklemeyi unutmayın:
// ... import { AuthRoutes } from './auth/auth.routes.config'; // ... routes.push(new AuthRoutes(app)); // independent: can go before or after UsersRoute // ...Node.js'yi yeniden başlatmaya ve şimdi test etmeye hazırız, daha önce test kullanıcımızı oluşturmak için kullandığımız kimlik bilgilerini eşleştirdiğimizden emin olun:
curl --request POST 'localhost:3000/auth' \ --header 'Content-Type: application/json' \ --data-raw '{ "password":"secr3tPass!23", "email":"[email protected]" }'Yanıt şöyle bir şey olacaktır:
{ "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJVZGdzUTBYMXciLCJlbWFpbCI6Im1hcmNvcy5oZW5yaXF1ZUB0b3B0YWwuY29tIiwicHJvdmlkZXIiOiJlbWFpbCIsInBlcm1pc3Npb25MZXZlbCI6MSwicmVmcmVzaEtleSI6ImtDN3JFdDFHUmNsWTVXM0N4dE9nSFE9PSIsImlhdCI6MTYxMTM0NTYzNiwiZXhwIjoxNjExMzgxNjM2fQ.cfI_Ey4RHKbOKFdVGsowePZlMeX3fku6WHFu0EMjFP8", "refreshToken": "cXBHZ2tJdUhucERaTVpMWVNTckhNenQwcy9Bd0VIQ2RXRnA4bVBJbTBuQVorcS9Qb2xOUDVFS2xEM1RyNm1vTGdoWWJqb2xtQ0NHcXhlWERUcG81d0E9PQ==" }Daha önce olduğu gibi, yukarıdaki değerleri kullanarak kolaylık olması için bazı ortam değişkenlerini ayarlayalım:

REST_API_EXAMPLE_ACCESS="put_your_access_token_here" REST_API_EXAMPLE_REFRESH="put_your_refresh_token_here"Harika! Erişim jetonumuz ve yenileme jetonumuz var, ancak onlarla faydalı bir şeyler yapabilecek bazı ara katman yazılımlarına ihtiyacımız var.
JWT Ara Yazılımı
JWT yapısını kodu çözülmüş biçiminde işlemek için yeni bir TypeScript türüne ihtiyacımız olacak. İçinde bununla common/types/jwt.ts oluşturun:
export type Jwt = { refreshKey: string; userId: string; permissionFlags: string; }; Bir yenileme belirtecinin varlığını kontrol etmek, bir yenileme belirtecini doğrulamak ve bir JWT'yi doğrulamak için ara katman işlevlerini uygulayalım. Üçü de yeni bir dosyaya girebilir, 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.
Kullanıcı İzinleri
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); } }; }Daha özelleştirilmiş bir durum, belirli bir kullanıcı kaydına erişebilmesi gereken kullanıcıların yalnızca aynı kullanıcı veya yönetici olmasıdır:
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(); } } } Son bir ara katman yazılımı parçası ekleyeceğiz, bu sefer 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(); } } Ve yukarıdaki işlev res.locals.user bağlı olduğundan, next() çağrısından önce bu değeri validateUserExists() içinde doldurabiliriz:
// ... if (user) { res.locals.user = user; next(); } else { // ... Aslında bunu validateUserExists() içinde yapmak validateSameEmailBelongToSameUser() () içinde gereksiz hale getirecektir. Oradaki veritabanı çağrımızı ortadan kaldırabilir, onu res.locals içinde önbelleğe alınmaya güvenebileceğimiz değerle değiştirebiliriz:
- const user = await userService.getUserByEmail(req.body.email); - if (user && user.id === req.params.userId) { + if (res.locals.user._id === req.params.userId) { Artık izinler mantığımızı users.routes.config.ts içine entegre etmeye hazırız.
İzin Gerektiren
İlk olarak, yeni ara katman yazılımımızı ve enum içe aktaracağız:
import jwtMiddleware from '../auth/middleware/jwt.middleware'; import permissionMiddleware from '../common/middleware/common.permission.middleware'; import { PermissionFlag } from '../common/middleware/common.permissionflag.enum';Kullanıcı listesinin yalnızca yönetici izinlerine sahip biri tarafından yapılan isteklerle erişilebilir olmasını istiyoruz, ancak yine de normal UX beklentileri akışında olduğu gibi yeni bir kullanıcı oluşturma yeteneğinin herkese açık olmasını istiyoruz. Kontrol cihazımızdan önce fabrika fonksiyonumuzu kullanarak ilk önce kullanıcı listesini kısıtlayalım:
this.app .route(`/users`) .get( jwtMiddleware.validJWTNeeded, permissionMiddleware.permissionFlagRequired( PermissionFlag.ADMIN_PERMISSION ), UsersController.listUsers ) // ... Buradaki fabrika çağrısının ( (...) ) bir ara katman yazılımı işlevi döndürdüğünü unutmayın; bu nedenle, tüm normal, fabrika dışı ara katman yazılımlarına çağrı yapılmadan başvurulur ( () ).
Diğer bir yaygın kısıtlama, userId içeren tüm rotalar için yalnızca aynı kullanıcının veya bir yöneticinin erişime sahip olmasını istememizdir:
.route(`/users/:userId`) - .all(UsersMiddleware.validateUserExists) + .all( + UsersMiddleware.validateUserExists, + jwtMiddleware.validJWTNeeded, + permissionMiddleware.onlySameUserOrAdminCanDoThisAction + ) .get(UsersController.getUserById) Ayrıca, PUT ve PATCH yollarının her birinin sonunda UsersController işlev başvurusundan hemen önce UsersMiddleware.userCantChangePermission ekleyerek kullanıcıların ayrıcalıklarını yükseltmelerini önleyeceğiz.
Ancak, REST API iş mantığımızın yalnızca PAID_PERMISSION sahip kullanıcıların bilgilerini güncellemesine izin verdiğini varsayalım. Bu, diğer projelerin iş gereksinimleriyle uyumlu olabilir veya olmayabilir: Yalnızca ücretli ve ücretsiz izin arasındaki farkı test etmek içindir.
Bu, az önce eklediğimiz userCantChangePermission referanslarının her birinin ardından başka bir jeneratör çağrısı ekleyerek yapılabilir:
permissionMiddleware.permissionFlagRequired( PermissionFlag.PAID_PERMISSION ),Bununla Node.js'yi yeniden başlatmaya ve denemeye hazırız.
Manuel İzin Testi
Rotaları test etmek için, bir erişim belirteci olmadan kullanıcı listesini GET deneyelim:
curl --include --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json'Geçerli bir JWT kullanmamız gerektiğinden bir HTTP 401 yanıtı alıyoruz. Daha önceki kimlik doğrulamamızdan bir erişim belirteci ile deneyelim:
curl --include --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS" Bu sefer bir HTTP 403 alıyoruz. Simgemiz geçerli, ancak ADMIN_PERMISSION sahip olmadığımız için bu uç noktayı kullanmamız yasak.
Yine de kendi kullanıcı kaydımızı GET için buna ihtiyacımız olmamalı:
curl --request GET "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS"Cevap:
{ "_id": "UdgsQ0X1w", "email": "[email protected]", "permissionFlags": 1, "__v": 0 } Buna karşılık, izin değerimiz 1 (yalnızca FREE_PERMISSION ) olduğundan, kendi kullanıcı kaydımızı güncellemeye çalışmak başarısız olmalıdır:
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" }'Yanıt beklendiği gibi 403'tür.
Bir okuyucu alıştırması olarak, yerel veri tabanındaki permissionFlags kullanıcısını değiştirmenizi ve /auth yeni bir gönderi yapmanızı (yeni permissionFlags ile bir belirteç oluşturmak için) ve ardından kullanıcıyı tekrar PATCH etmeye çalışmanızı öneririm. İş mantığımız ADMIN_PERMISSION tek başına diğer kullanıcılara ve hatta kendinize yama yapmanıza izin vermediğini belirttiğinden, bayrakları PAID_PERMISSION veya ALL_PERMISSIONS sayısal değerine ayarlamanız gerekeceğini unutmayın.
/auth için yeni bir gönderi gereksinimi, akılda tutulmaya değer bir güvenlik senaryosu getirir. Bir site sahibi, örneğin, hatalı davranan bir kullanıcıyı kilitlemeye çalışmak için bir kullanıcının izinlerini değiştirdiğinde, kullanıcı bir sonraki JWT yenilemesine kadar bunun etkili olduğunu görmez. Bunun nedeni, izin denetiminin, fazladan bir veritabanı isabetinden kaçınmak için JWT verilerini kullanmasıdır.
Auth0 gibi hizmetler, otomatik jeton döndürme sunarak yardımcı olabilir, ancak kullanıcılar, normalde ne kadar kısa olursa olsun, rotasyonlar arasındaki süre boyunca beklenmeyen uygulama davranışlarıyla karşılaşmaya devam edecek. Bunu azaltmak için geliştiriciler, izin değişikliklerine yanıt olarak yenileme belirteçlerini aktif olarak iptal etmeye özen göstermelidir.
Geliştiriciler, bir REST API üzerinde çalışırken, bir yığın cURL komutunu periyodik olarak yeniden çalıştırarak olası hatalara karşı koruma sağlayabilirler. Ancak bu yavaştır ve hataya açıktır ve hızla sıkıcı hale gelir.
Otomatik Test
Bir API büyüdükçe, özellikle sık sık değişen iş mantığı ile yazılım kalitesini korumak zorlaşır. API hatalarını olabildiğince azaltmak ve yeni değişiklikleri güvenle dağıtmak için, bir uygulamanın ön ucu ve/veya arka ucu için bir test paketine sahip olmak çok yaygındır.
Testler ve test edilebilir kodlar yazmaya dalmak yerine, bazı temel mekanikleri göstereceğiz ve okuyucuların üzerine geliştirmeleri için çalışan bir test paketi sağlayacağız.
Test Verisi Artıklarıyla Başa Çıkma
Otomatikleştirmeden önce, test verileriyle ne olduğunu düşünmeye değer.
Yerel veritabanımızı çalıştırmak için Docker Compose kullanıyoruz ve bu veritabanını canlı üretim veri kaynağı olarak değil, geliştirme için kullanmayı umuyoruz. Burada çalıştıracağımız testler, onları her çalıştırdığımızda geride yeni bir test verisi seti bırakarak yerel veritabanını etkileyecektir. Çoğu durumda bu bir sorun olmamalı, ancak öyleyse, okuyuculara test amacıyla yeni bir veritabanı oluşturmak için docker-compose.yml değiştirme alıştırmasını bırakıyoruz.
Gerçek dünyada, geliştiriciler genellikle sürekli entegrasyon hattının bir parçası olarak otomatik testler gerçekleştirir. Bunu yapmak için, her test çalıştırması için geçici bir veritabanı oluşturmanın bir yolunu boru hattı düzeyinde yapılandırmak mantıklı olacaktır.
Testlerimizi oluşturmak için Mocha, Chai ve SuperTest kullanacağız:
npm i --save-dev chai mocha supertest @types/chai @types/express @types/mocha @types/supertest ts-nodeMocha, uygulamamızı yönetecek ve testleri çalıştıracak, Chai daha okunaklı test ifadesine izin verecek ve SuperTest, API'mizi bir REST istemcisinin yaptığı gibi çağırarak uçtan uca (E2E) testi kolaylaştıracak.
package.json adresindeki betiklerimizi güncellememiz gerekecek:
"scripts": { // ... "test": "mocha -r ts-node/register 'test/**/*.test.ts' --unhandled-rejections=strict", "test-debug": "export DEBUG=* && npm test" }, Bu, test adı verilen oluşturacağımız bir klasörde testler yapmamızı sağlayacaktır.
Bir Meta-test
Test altyapımızı denemek için bir dosya oluşturalım, test/app.test.ts :
import { expect } from 'chai'; describe('Index Test', function () { it('should always pass', function () { expect(true).to.equal(true); }); }); Buradaki sözdizimi alışılmadık görünebilir, ancak doğru. Testleri, buna ( expect() ileteceğimiz bir fonksiyonun gövdesini kastettiğimiz it() it() blokları içindeki davranışı bekleyerek describe() blokları içinde çağrılır.
Şimdi, terminalde koşacağız:
npm run testBunu görmeliyiz:
> mocha -r ts-node/register 'test/**/*.test.ts' --unhandled-rejections=strict Index Test ✓ should always pass 1 passing (6ms)Harika! Test kitaplıklarımız kurulu ve kullanıma hazır.
Testi Kolaylaştırma
Test çıktısını temiz tutmak için, normal test çalıştırmaları sırasında Winston istek günlüğünü tamamen susturmak isteyeceğiz. Bu, Mocha'daki it() işlevinin mevcut olup olmadığını algılamak için app.ts hata ayıklama olmayan else şubemizde hızlı bir değişiklik yapmak kadar kolaydır:
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 + } } Eklememiz gereken son bir dokunuş, testlerimiz tarafından tüketilmek üzere app.ts dışa aktarmaktır. app.ts sonunda, server.listen() 'den hemen önce export default ekleyeceğiz, çünkü listen() Node.js http.Server döndürür.
Yığını kırmadığımızı kontrol etmek için hızlı bir npm run test ile artık API'mizi test etmeye hazırız.
İlk Gerçek REST API Otomatik Testimiz
Kullanıcı testlerimizi yapılandırmaya başlamak için, gerekli içe aktarma ve test değişkenleriyle başlayarak test/users/users.test.ts oluşturalım:
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'; Daha sonra, bazı kurulum ve sökme tanımlarıyla en dışta bir tanım describe() bloğu oluşturacağız:
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); }); }); }); Before before() ve after() öğelerine geçmekte olduğumuz fonksiyonlar, aynı describe() bloğu içinde it() çağırarak tanımlayacağımız tüm testlerden önce ve sonra çağrılır. after() işlevine iletilen işlev, yalnızca hem uygulamayı hem de veritabanı bağlantısını temizledikten sonra çağrılmasını sağladığımız done geri çağrısını alır.
Not: After after() taktiğimiz olmadan, Mocha testin başarılı bir şekilde tamamlanmasından sonra bile askıda kalacaktır. Tavsiye genellikle bundan kaçınmak için Mocha'yı her zaman --exit ile çağırmaktır, ancak (genellikle bahsedilmeyen) bir uyarı vardır. Test paketi, test paketinde veya uygulamanın kendisinde yanlış yapılandırılmış bir Söz gibi başka nedenlerle askıda kalırsa, --exit ile Mocha beklemez ve hata ayıklamaya ince bir komplikasyon ekleyerek başarıyı yine de bildirir.
Şimdi describe() bloğu içine bireysel E2E testleri eklemeye hazırız:
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; }); Bu ilk işlev bizim için yeni bir kullanıcı yaratacaktır - kullanıcı e-postamız daha önce shortid kullanılarak oluşturulduğundan benzersiz bir kullanıcı. request değişkeni, API'mize HTTP istekleri yapmamızı sağlayan bir SuperTest aracısı içerir. Onları wait kullanarak await , bu yüzden it() ilettiğimiz işlevin async olması gerekiyor. Daha sonra sonucun çeşitli yönlerini test etmek için Chai'den wait expect() işlevini kullanırız.
Bu noktada bir npm run test , yeni testimizin çalıştığını göstermelidir.
Bir Test Zinciri
Aşağıdaki it() bloklarının hepsini describe() ) bloğumuzun içine ekleyeceğiz. firstUserIdTest gibi mutasyona uğrattığımız değişkenlerle çalışabilmeleri için bunları sunulan sırayla eklemeliyiz.
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; });Burada yeni oluşturulan kullanıcımız için yeni bir erişim ve yenileme belirteci getiriyoruz.
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); }); Bu, kullanıcı verileri yanıtının başlangıçta gönderdiğimiz yanıtla eşleşip eşleşmediğini kontrol etmek için :userId yoluna belirteç taşıyan bir GET isteği yapar.
Testlerde Yerleştirme, Atlama, Yalıtma ve Dengeleme
Mocha'da, it() blokları kendi tanım describe() ) bloklarını da içerebilir, bu nedenle bir sonraki testimizi başka bir describe() bloğu içine yerleştireceğiz. Bu, sonunda göstereceğimiz gibi, bağımlılık dizimizi test çıktısında daha net hale getirecektir.
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); }); });Etkili testler, yalnızca çalışmayı umduğumuz şeyleri değil, aynı zamanda başarısız olmayı umduğumuz şeyleri de kapsar. Burada tüm kullanıcıları listelemeye çalışıyoruz ve kullanıcımızın (varsayılan izinlere sahip) bu uç noktayı kullanmasına izin verilmediğinden 403 yanıtı bekliyoruz.
Bu yeni describe() bloğu içinde testler yazmaya devam edebiliriz. Test kodunun geri kalanında kullanılan özellikleri zaten tartıştığımız için, depoda bu satırdan başlayarak bulunabilir.
Mocha, testler geliştirirken ve hata ayıklarken kullanılabilecek bazı özellikler sunar:
-
.skip()yöntemi, tek bir testi veya tüm bir test bloğunu çalıştırmaktan kaçınmak için kullanılabilir.it(),it.skip()ile değiştirildiğinde (benzer şekildedescribe()için), söz konusu test veya testler çalıştırılmayacak, ancak Mocha'nın son çıktısında “beklemede” olarak sayılacaktır. - Daha da geçici kullanım için,
.only()işlevi, .only(.only()olmayan tüm testlerin tamamen yok sayılmasına neden olur ve hiçbir şeyin “beklemede” olarak işaretlenmesiyle sonuçlanmaz. -
package.jsontanımlandığı gibimochaçağrılması, komut satırı parametresi olarak--bailkullanabilir. Bu ayarlandığında, bir test başarısız olur olmaz Mocha testleri çalıştırmayı durdurur. Testler kademeli olarak ayarlandığından, bu özellikle REST API örnek projemizde kullanışlıdır; Mocha, yalnızca ilk test bozulursa, şu anda bu nedenle başarısız olan tüm bağımlı (ancak bozuk olmayan) testlerden şikayet etmek yerine tam olarak bunu bildirir.
Bu noktada npm run test ile çalıştırırsak, başarısız olan üç test görürüz. (Şu an için güvendikleri işlevleri uygulamadan bırakacak olsaydık, bu üç test .skip() için iyi adaylar olurdu.)
Başarısız testler, şu anda uygulamamızda eksik olan iki parçaya dayanıyor. İlki users.routes.config.ts içinde:
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, ]); Güncellememiz gereken ikinci dosya, burada mevcut olmayan bir fonksiyona başvurduğumuz için users.controller.ts . import { PatchUserDto } from '../dto/patch.user.dto'; üste yakın ve sınıftaki eksik işlev:
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(); }Bu tür ayrıcalık yükseltme yeteneklerinin eklenmesi, test için yararlıdır, ancak çoğu gerçek dünya gereksinimlerine uymaz. Burada okuyucu için iki alıştırma var:
- Kodun, kullanıcıların kendi
permissionFlagsBayraklarını değiştirmesine izin vermemesine ve izinlerle sınırlı uç noktaların test edilmesine izin vermenin yollarını düşünün. -
permissionFlagsBayraklarının API aracılığıyla nasıl değişebileceğine ilişkin iş mantığı (ve ilgili testler) oluşturun ve uygulayın. (Burada bir tavuk-yumurta bulmacası var: Belirli bir kullanıcı ilk etapta izinleri değiştirme iznini nasıl alır?)
Bununla, npm run test , şu şekilde güzel biçimlendirilmiş çıktılarla başarıyla tamamlanmalıdır:
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)Artık REST API'mizin beklendiği gibi çalıştığını hızlı bir şekilde doğrulamanın bir yolu var.
Hata Ayıklama (İle) Testleri
Beklenmeyen test hatalarıyla karşılaşan geliştiriciler, test paketini çalıştırırken hem Winston hem de Node.js'nin hata ayıklama modülünden kolayca yararlanabilir.
Örneğin, DEBUG=mquery npm run test çağırarak hangi Mongoose sorgularının yürütüldüğüne odaklanmak kolaydır. (Bu komutun ortadaki export önekinden ve && 'den nasıl yoksun olduğuna dikkat edin, bu da ortamın sonraki komutlara devam etmesine neden olur.)
Ayrıca, package.json daha önce yaptığımız ekleme sayesinde, tüm hata ayıklama çıktılarını npm run test-debug ile göstermek de mümkündür.
Bununla birlikte, kullanışlı bir otomatik test paketine sahip, çalışan, ölçeklenebilir, MongoDB destekli bir REST API'ye sahibiz. Ama yine de bazı temel unsurlar eksik.
Güvenlik (Tüm Projelerde Kask Takılmalıdır)
Express.js ile çalışırken, belgeler, özellikle de en iyi güvenlik uygulamaları mutlaka okunmalıdır. En azından, takip etmeye değer:
- TLS desteğini yapılandırma
- Hız sınırlayıcı ara katman yazılımı ekleme
- Npm bağımlılıklarının güvenli olduğundan emin olmak (okuyucular
npm auditbaşlamak veya snyk ile daha derine inmek isteyebilir) - Yaygın güvenlik açıklarına karşı korunmaya yardımcı olmak için Kask kitaplığını kullanma
Bu son noktayı örnek projemize eklemek kolaydır:
npm i --save helmet Ardından, app.ts içinde, yalnızca onu içe aktarmamız ve başka bir app.use() çağrısı eklememiz gerekir:
import helmet from 'helmet'; // ... app.use(helmet());Belgelerinin belirttiği gibi, Kask (herhangi bir güvenlik eklentisi gibi) gümüş kurşun değildir, ancak her türlü önleme yardımcı olur.
Docker ile REST API Projemizi İçeren
Bu seride Docker kapsayıcılarına derinlemesine girmedik, ancak MongoDB'yi Docker Compose ile bir kapsayıcıda kullandık. Docker'a aşina olmayan ancak bir adım daha denemek isteyen okuyucular, proje kökünde Dockerfile (uzantısız) adlı bir dosya oluşturabilir:
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"] Bu yapılandırma, Docker'dan node:14-slim resmi görüntüsü ile başlar ve bir kapsayıcıda örnek REST API'mizi oluşturur ve çalıştırır. Konfigürasyon durumdan duruma değişebilir, ancak bu genel görünümlü varsayılanlar projemiz için çalışır.
Resmi oluşturmak için, bunu proje kökünde çalıştırırız (istediğiniz gibi tag_your_image_here yerine):
docker build . -t tag_your_image_hereArdından, aynı metin değişimini varsayarak arka uçumuzu çalıştırmanın bir yolu şudur:
docker run -p 3000:3000 tag_your_image_here Bu noktada MongoDB ve Node.js her ikisi de Docker'ı kullanabilir, ancak onları iki farklı şekilde başlatmamız gerekiyor. Ana Node.js uygulamasını docker-compose.yml eklemeyi okuyucuya bir alıştırma olarak bırakıyoruz, böylece tüm uygulama tek bir docker-compose komutuyla başlatılabilir.
Keşfedilecek Daha Fazla REST API Becerisi
Bu makalede, REST API'mizde kapsamlı iyileştirmeler yaptık: Kapsayıcılı bir MongoDB ekledik, Mongoose ve ekspres doğrulayıcıyı yapılandırdık, JWT tabanlı kimlik doğrulama ve esnek bir izin sistemi ekledik ve bir dizi otomatik test yazdık.
Bu, hem yeni hem de gelişmiş arka uç geliştiriciler için sağlam bir başlangıç noktasıdır. Yine de bazı yönlerden projemiz üretim kullanımı, ölçeklendirme ve bakım için ideal olmayabilir. Bu makale boyunca serpiştirdiğimiz okuyucu alıştırmalarının yanı sıra, öğrenilecek başka ne var?
API düzeyinde, OpenAPI uyumlu bir spesifikasyon oluşturmayı okumanızı öneririz. Özellikle kurumsal geliştirme ile ilgilenen okuyucular, NestJS'yi de denemek isteyeceklerdir. Bu, Express.js'nin üzerine inşa edilmiş başka bir çerçevedir, ancak daha sağlam ve soyuttur; bu nedenle, öncelikle Express.js'nin temellerini öğrenmek için örnek projemizi kullanmak iyidir. Daha az önemli olmayan, API'lere yönelik GraphQL yaklaşımı, REST'e alternatif olarak yaygın bir ilgiye sahiptir.
İzinler söz konusu olduğunda, manuel olarak tanımlanmış bayraklar için bir ara yazılım oluşturucu ile bit düzeyinde bayraklar yaklaşımını ele aldık. Ölçekleme sırasında daha fazla kolaylık sağlamak için Mongoose ile entegre olan CASL kitaplığına bakmaya değer. Can can(['update', 'delete'], '(model name here)', { creator: 'me' }); tam bir özel ara katman işlevi yerine.
Bu projede pratik bir otomatik test sıçrama tahtası sağladık, ancak bazı önemli konular kapsamımızın dışındaydı. Okuyuculara şunları öneriyoruz:
- Bileşenleri ayrı ayrı test etmek için birim testini keşfedin; bunun için Mocha ve Chai de kullanılabilir.
- Test sırasında çalıştırılmayan kod satırlarını göstererek test takımlarındaki boşlukları belirlemeye yardımcı olan kod kapsamı araçlarına bakın. Bu tür araçlarla, okuyucular daha sonra örnek testleri gerektiği gibi tamamlayabilirler - ancak kullanıcıların izinlerini bir
PATCHaracılığıyla/users/:userIdolarak değiştirip değiştiremeyecekleri gibi her eksik senaryoyu ortaya çıkarmayabilirler. - Otomatik test için diğer yaklaşımları deneyin. Chai'den davranışa dayalı geliştirme (BDD) stili
expectarabirimini kullandık, ancak bu aynı zamanda mustshould()veassertde destekliyor. Jest gibi diğer test kitaplıklarını da öğrenmeye değer.
Bu konuların yanı sıra Node.js/TypeScript REST API'miz üzerine inşa edilmeye hazır. Özellikle okuyucular, standart kullanıcı kaynağı çevresinde ortak iş mantığını uygulamak için daha fazla ara katman yazılımı uygulamak isteyebilir. Burada daha derine inmeyeceğim, ancak kendilerini engellenmiş bulan okuyuculara rehberlik ve ipuçları vermekten memnuniyet duyarım - aşağıya bir yorum bırakın.
Bu projenin tam kodu, açık kaynaklı bir GitHub deposu olarak mevcuttur.
Toptal Mühendislik Blogunda Daha Fazla Okuma:
- Söze Dayalı Hata İşleme için Express.js Yollarını Kullanma
