Node.jsでのセキュアRESTAPIの作成

公開: 2022-03-11

アプリケーションプログラミングインターフェイス(API)はいたるところにあります。 これらは、ソフトウェアが他のソフトウェア(内部または外部)と一貫して通信できるようにします。これは、再利用性は言うまでもなく、スケーラビリティの重要な要素です。

今日、オンラインサービスが公開APIを使用することは非常に一般的です。 これらにより、他の開発者はソーシャルメディアログイン、クレジットカード支払い、行動追跡などの機能を簡単に統合できます。 彼らがこれに使用する事実上の標準は、REpresentational State Transfer(REST)と呼ばれます。

このタスクでは、ASP.NET Core、Laravel(PHP)、Bottle(Python)など、多数のプラットフォームとプログラミング言語を使用できますが、このチュートリアルでは、基本的で安全なRESTAPIバックエンドを使用して構築します。次のスタック:

  • Node.jsは、読者がすでにある程度理解している必要があります
  • Expressは、Node.jsでの一般的なWebサーバータスクの構築を大幅に簡素化し、RESTAPIバックエンドを構築する際の標準的な料金です。
  • Mongoose。バックエンドをMongoDBデータベースに接続します

このチュートリアルに従う開発者は、ターミナル(またはコマンドプロンプト)にも慣れている必要があります。

注:ここではフロントエンドのコードベースについては説明しませんが、バックエンドがJavaScriptで記述されているため、フルスタック全体でコード(オブジェクトモデルなど)を共有すると便利です。

RESTAPIの構造

REST APIは、ステートレス操作の一般的なセットを使用してデータにアクセスして操作するために使用されます。 これらの操作はHTTPプロトコルに不可欠であり、重要な作成、読み取り、更新、および削除(CRUD)機能を表しますが、クリーンな1対1の方法ではありません。

  • POST (リソースを作成するか、通常はデータを提供します)
  • GET (リソースまたは個々のリソースのインデックスを取得する)
  • PUT (リソースの作成または置換)
  • PATCH (リソースの更新/変更)
  • DELETE (リソースを削除)

これらのHTTP操作とリソース名をアドレスとして使用して、操作ごとにエンドポイントを作成することでRESTAPIを構築できます。 また、パターンを実装することで、安定したわかりやすい基盤が得られ、コードを迅速に進化させ、後で維持することができます。 前述のように、サードパーティの機能を統合するために同じ基盤が使用され、そのほとんどは同様にREST APIを使用して、そのような統合をより高速にします。

とりあえず、Node.jsを使用して安全なRESTAPIの作成を始めましょう。

このチュートリアルでは、 usersというリソース用の非常に一般的な(そして非常に実用的な)RESTAPIを作成します。

リソースの基本構造は次のとおりです。

  • id (自動生成されたUUID)
  • firstName
  • lastName
  • email
  • password
  • permissionLevel (このユーザーは何をすることができますか?)

そして、そのリソースに対して次の操作を作成します。

  • エンドポイント/usersでのPOST (新しいユーザーの作成)
  • エンドポイント/usersGET (すべてのユーザーを一覧表示)
  • エンドポイント/users/:userIdGET (特定のユーザーを取得)
  • エンドポイント/users/:userIdPATCH (特定のユーザーのデータを更新します)
  • エンドポイント/users/:userIdDELETE (特定のユーザーを削除)

また、アクセストークンにはJSON Webトークン(JWT)を使用します。 そのために、ユーザーの電子メールとパスワードを期待するauthという別のリソースを作成し、その代わりに、特定の操作での認証に使用されるトークンを生成します。 (Javaでの安全なRESTアプリケーションのためのJWTに関するDejan Milosevicのすばらしい記事では、これについてさらに詳しく説明しています。原則は同じです。)

RESTAPIチュートリアルのセットアップ

まず、最新のNode.jsバージョンがインストールされていることを確認してください。 この記事では、バージョン14.9.0を使用します。 古いバージョンでも動作する可能性があります。

次に、MongoDBがインストールされていることを確認します。 ここで使用するMongooseとMongoDBの詳細については説明しませんが、基本を実行するには、サーバーをサービスとしてではなく、対話モードで(つまり、コマンドラインからmongoとして)起動するだけです。 これは、このチュートリアルのある時点で、Node.jsコードを介してではなく、MongoDBと直接対話する必要があるためです。

注:MongoDBでは、一部のRDBMSシナリオのように特定のデータベースを作成する必要はありません。 Node.jsコードからの最初の挿入呼び出しは、その作成を自動的にトリガーします。

このチュートリアルには、作業中のプロジェクトに必要なすべてのコードが含まれているわけではありません。 代わりに、コンパニオンリポジトリのクローンを作成し、読み通すときにハイライトをたどるだけですが、必要に応じて、必要に応じてリポジトリから特定のファイルやスニペットをコピーすることもできます。

ターミナルで結果のrest-api-tutorial/フォルダーに移動します。 プロジェクトに3つのモジュールフォルダが含まれていることがわかります。

  • common (すべての共有サービス、およびユーザーモジュール間で共有される情報の処理)
  • users (ユーザーに関するすべて)
  • auth (JWT生成とログインフローの処理)

ここで、 npm install (または、お持ちの場合はyarn )を実行します。

おめでとうございます。これで、単純なRESTAPIバックエンドを実行するために必要なすべての依存関係とセットアップが完了しました。

ユーザーモジュールの作成

MongoDBのオブジェクトデータモデリング(ODM)ライブラリであるMongooseを使用して、ユーザースキーマ内にユーザーモデルを作成します。

まず、/ users / models/ /users/models/users.model.jsにMongooseスキーマを作成する必要があります。

 const userSchema = new Schema({ firstName: String, lastName: String, email: String, password: String, permissionLevel: Number });

スキーマを定義すると、スキーマをユーザーモデルに簡単にアタッチできます。

 const userModel = mongoose.model('Users', userSchema);

その後、このモデルを使用して、Expressエンドポイント内で必要なすべてのCRUD操作を実装できます。

users/routes.config.jsでルートを定義することにより、「ユーザーの作成」操作から始めましょう。

 app.post('/users', [ UsersController.insert ]);

これは、メインのindex.jsファイルのExpressアプリに取り込まれます。 UsersControllerオブジェクトはコントローラーからインポートされ、/ users / controllers/ /users/controllers/users.controller.jsで定義されているパスワードを適切にハッシュします。

 exports.insert = (req, res) => { let salt = crypto.randomBytes(16).toString('base64'); let hash = crypto.createHmac('sha512',salt) .update(req.body.password) .digest("base64"); req.body.password = salt + "$" + hash; req.body.permissionLevel = 1; UserModel.createUser(req.body) .then((result) => { res.status(201).send({id: result._id}); }); };

この時点で、サーバーを実行し( npm start )、JSONデータを含むPOSTリクエストを/usersに送信することで、Mongooseモデルをテストできます。

 { "firstName" : "Marcos", "lastName" : "Silva", "email" : "[email protected]", "password" : "s3cr3tp4sswo4rd" }

これに使用できるツールがいくつかあります。 不眠症(以下で説明)とPostmanは人気のあるGUIツールであり、 curlは一般的なCLIの選択肢です。 たとえば、ブラウザの組み込み開発ツールコンソールからJavaScriptを使用することもできます。

 fetch('http://localhost:3600/users', { method: 'POST', headers: { "Content-type": "application/json" }, body: JSON.stringify({ "firstName": "Marcos", "lastName": "Silva", "email": "[email protected]", "password": "s3cr3tp4sswo4rd" }) }) .then(function(response) { return response.json(); }) .then(function(data) { console.log('Request succeeded with JSON response', data); }) .catch(function(error) { console.log('Request failed', error); });

この時点で、有効な投稿の結果は、作成されたユーザーからのIDになります: { "id": "5b02c5c84817bf28049e58a3" } 。 また、 users/models/users.model.jsのモデルにcreateUserメソッドを追加する必要があります。

 exports.createUser = (userData) => { const user = new User(userData); return user.save(); };

これで、ユーザーが存在するかどうかを確認する必要があります。 そのために、次のエンドポイントに「idでユーザーを取得」機能を実装します: users/:userId

まず、/ users / /users/routes/config.js /config.jsにルートを作成します。

 app.get('/users/:userId', [ UsersController.getById ]);

次に、/ users / /users/controllers/users.controller.jsにコントローラーを作成します。

 exports.getById = (req, res) => { UserModel.findById(req.params.userId).then((result) => { res.status(200).send(result); }); };

そして最後に、 findByIdメソッドを/users/models/users.model.jsのモデルに追加します。

 exports.findById = (id) => { return User.findById(id).then((result) => { result = result.toJSON(); delete result._id; delete result.__v; return result; }); };

応答は次のようになります。

 { "firstName": "Marcos", "lastName": "Silva", "email": "[email protected]", "password": "Y+XZEaR7J8xAQCc37nf1rw==$p8b5ykUx6xpC6k8MryDaRmXDxncLumU9mEVabyLdpotO66Qjh0igVOVerdqAh+CUQ4n/E0z48mp8SDTpX2ivuQ==", "permissionLevel": 1, "id": "5b02c5c84817bf28049e58a3" }

ハッシュされたパスワードが表示されることに注意してください。 このチュートリアルでは、パスワードを示していますが、ハッシュされている場合でも、パスワードを公開しないことをお勧めします。 もう1つ確認できるのは、 permissionLevelです。これは、後でユーザー権限を処理するために使用します。

上記のパターンを繰り返して、ユーザーを更新する機能を追加できるようになりました。 変更したいフィールドのみを送信できるようになるため、 PATCH操作を使用します。 したがって、ルートは/users/:useridへのPATCHになり、変更するフィールドを送信します。 また、変更は問題のユーザーまたは管理者に制限する必要があり、管理者のみがpermissionLevelを変更できる必要があるため、追加の検証を実装する必要があります。 とりあえずそれをスキップして、authモジュールを実装したらそれに戻ります。 今のところ、コントローラーは次のようになります。

 exports.patchById = (req, res) => { if (req.body.password){ let salt = crypto.randomBytes(16).toString('base64'); let hash = crypto.createHmac('sha512', salt).update(req.body.password).digest("base64"); req.body.password = salt + "$" + hash; } UserModel.patchUser(req.params.userId, req.body).then((result) => { res.status(204).send({}); }); };

デフォルトでは、リクエストが成功したことを示すために、応答本文のないHTTPコード204を送信します。

そして、 patchUserメソッドをモデルに追加する必要があります。

 exports.patchUser = (id, userData) => { return User.findOneAndUpdate({ _id: id }, userData); };

ユーザーリストは、次のコントローラーによって/users/GETとして実装されます。

 exports.list = (req, res) => { let limit = req.query.limit && req.query.limit <= 100 ? parseInt(req.query.limit) : 10; let page = 0; if (req.query) { if (req.query.page) { req.query.page = parseInt(req.query.page); page = Number.isInteger(req.query.page) ? req.query.page : 0; } } UserModel.list(limit, page).then((result) => { res.status(200).send(result); }) };

対応するモデルメソッドは次のようになります。

 exports.list = (perPage, page) => { return new Promise((resolve, reject) => { User.find() .limit(perPage) .skip(perPage * page) .exec(function (err, users) { if (err) { reject(err); } else { resolve(users); } }) }); };

結果のリスト応答は、次の構造になります。

 [ { "firstName": "Marco", "lastName": "Silva", "email": "[email protected]", "password": "z4tS/DtiH+0Gb4J6QN1K3w==$al6sGxKBKqxRQkDmhnhQpEB6+DQgDRH2qr47BZcqLm4/fphZ7+a9U+HhxsNaSnGB2l05Oem/BLIOkbtOuw1tXA==", "permissionLevel": 1, "id": "5b02c5c84817bf28049e58a3" }, { "firstName": "Paulo", "lastName": "Silva", "email": "[email protected]", "password": "wTsqO1kHuVisfDIcgl5YmQ==$cw7RntNrNBNw3MO2qLbx959xDvvrDu4xjpYfYgYMxRVDcxUUEgulTlNSBJjiDtJ1C85YimkMlYruU59rx2zbCw==", "permissionLevel": 1, "id": "5b02d038b653603d1ca69729" } ]

そして、実装される最後の部分は、 /users/:userIdDELETEです。

削除するコントローラーは次のとおりです。

 exports.removeById = (req, res) => { UserModel.removeById(req.params.userId) .then((result)=>{ res.status(204).send({}); }); };

以前と同じように、コントローラーは確認としてHTTPコード204を返し、コンテンツ本文は返しません。

対応するモデルメソッドは次のようになります。

 exports.removeById = (userId) => { return new Promise((resolve, reject) => { User.deleteMany({_id: userId}, (err) => { if (err) { reject(err); } else { resolve(err); } }); }); };

これで、ユーザーリソースを操作するために必要なすべての操作が完了し、ユーザーコントローラーが完成しました。 このコードの主なアイデアは、RESTパターンを使用するためのコアコンセプトを提供することです。 このコードに戻って検証と権限を実装する必要がありますが、最初にセキュリティの構築を開始する必要があります。 authモジュールを作成しましょう。

認証モジュールの作成

パーミッションと検証ミドルウェアを実装してusersモジュールを保護する前に、現在のユーザーの有効なトークンを生成できる必要があります。 ユーザーが有効なメールアドレスとパスワードを提供したことに応じて、JWTを生成します。 JWTは注目に値するJSONWebトークンであり、ユーザーが繰り返し検証することなく、複数のリクエストを安全に実行できるようにするために使用できます。 通常は有効期限があり、通信を安全に保つために数分ごとに新しいトークンが再作成されます。 ただし、このチュートリアルでは、トークンの更新を省略し、ログインごとに1つのトークンを使用してトークンをシンプルに保ちます。

まず、 /authリソースへのPOSTリクエストのエンドポイントを作成します。 リクエストの本文には、ユーザーのメールアドレスとパスワードが含まれます。

 { "email" : "[email protected]", "password" : "s3cr3tp4sswo4rd2" }

コントローラを使用する前に、/authorization/middlewares/ /authorization/middlewares/verify.user.middleware.jsでユーザーを検証する必要があります。

 exports.isPasswordAndUserMatch = (req, res, next) => { UserModel.findByEmail(req.body.email) .then((user)=>{ if(!user[0]){ res.status(404).send({}); }else{ let passwordFields = user[0].password.split('$'); let salt = passwordFields[0]; let hash = crypto.createHmac('sha512', salt).update(req.body.password).digest("base64"); if (hash === passwordFields[1]) { req.body = { userId: user[0]._id, email: user[0].email, permissionLevel: user[0].permissionLevel, provider: 'email', name: user[0].firstName + ' ' + user[0].lastName, }; return next(); } else { return res.status(400).send({errors: ['Invalid email or password']}); } } }); };

これが完了したら、コントローラーに移動してJWTを生成できます。

 exports.login = (req, res) => { try { let refreshId = req.body.userId + jwtSecret; let salt = crypto.randomBytes(16).toString('base64'); let hash = crypto.createHmac('sha512', salt).update(refreshId).digest("base64"); req.body.refreshKey = salt; let token = jwt.sign(req.body, jwtSecret); let b = Buffer.from(hash); let refresh_token = b.toString('base64'); res.status(201).send({accessToken: token, refreshToken: refresh_token}); } catch (err) { res.status(500).send({errors: err}); } };

このチュートリアルではトークンを更新しませんが、コントローラーは、そのような生成を有効にして、後続の開発での実装を容易にするように設定されています。

ここで必要なのは、ルートを作成し、/authorization/ /authorization/routes.config.jsで適切なミドルウェアを呼び出すことだけです。

 app.post('/auth', [ VerifyUserMiddleware.hasAuthValidFields, VerifyUserMiddleware.isPasswordAndUserMatch, AuthorizationController.login ]);

応答には、生成されたJWTがaccessTokenフィールドに含まれます。

 { "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI1YjAyYzVjODQ4MTdiZjI4MDQ5ZTU4YTMiLCJlbWFpbCI6Im1hcmNvcy5oZW5yaXF1ZUB0b3B0YWwuY29tIiwicGVybWlzc2lvbkxldmVsIjoxLCJwcm92aWRlciI6ImVtYWlsIiwibmFtZSI6Ik1hcmNvIFNpbHZhIiwicmVmcmVzaF9rZXkiOiJiclhZUHFsbUlBcE1PakZIRG1FeENRPT0iLCJpYXQiOjE1MjY5MjMzMDl9.mmNg-i44VQlUEWP3YIAYXVO-74803v1mu-y9QPUQ5VY", "refreshToken": "U3BDQXBWS3kyaHNDaGJNanlJTlFkSXhLMmFHMzA2NzRsUy9Sd2J0YVNDTmUva0pIQ0NwbTJqOU5YZHgxeE12NXVlOUhnMzBWMGNyWmdOTUhSaTdyOGc9PQ==" }

トークンを作成したら、 Bearer ACCESS_TOKENの形式を使用してAuthorizationヘッダー内で使用できます。

パーミッションと検証ミドルウェアの作成

最初に定義する必要があるのは、 usersリソースを誰が使用できるかです。 処理する必要のあるシナリオは次のとおりです。

  • ユーザーを作成するためのパブリック(登録プロセス)。 このシナリオではJWTを使用しません。
  • ログインしたユーザーと管理者がそのユーザーを更新するためのプライベート。
  • ユーザーアカウントを削除するための管理者専用のプライベート。

これらのシナリオを特定したら、最初に、ユーザーが有効なJWTを使用しているかどうかを常に検証するミドルウェアが必要になります。 /common/middlewares/auth.validation.middleware.jsのミドルウェアは、次のように単純にすることができます。

 exports.validJWTNeeded = (req, res, next) => { if (req.headers['authorization']) { try { let authorization = req.headers['authorization'].split(' '); if (authorization[0] !== 'Bearer') { return res.status(401).send(); } else { req.jwt = jwt.verify(authorization[1], secret); return next(); } } catch (err) { return res.status(403).send(); } } else { return res.status(401).send(); } };

リクエストエラーの処理にはHTTPエラーコードを使用します。

  • 無効なリクエストのHTTP401
  • 無効なトークンを持つ有効なリクエスト、または無効な権限を持つ有効なトークンのHTTP 403

ビットごとのAND演算子(ビットマスキング)を使用して、アクセス許可を制御できます。 必要な各パーミッションを2の累乗として設定すると、32ビット整数の各ビットを1つのパーミッションとして扱うことができます。 管理者は、権限値を2147483647に設定することにより、すべての権限を持つことができます。そのユーザーは、任意のルートにアクセスできます。 別の例として、パーミッション値が7に設定されているユーザーは、値1、2、および4(2の0、1、および2の累乗)のビットでマークされたロールへのパーミッションを持ちます。

そのためのミドルウェアは次のようになります。

 exports.minimumPermissionLevelRequired = (required_permission_level) => { return (req, res, next) => { let user_permission_level = parseInt(req.jwt.permission_level); let user_id = req.jwt.user_id; if (user_permission_level & required_permission_level) { return next(); } else { return res.status(403).send(); } }; };

ミドルウェアは汎用です。 ユーザーのアクセス許可レベルと必要なアクセス許可レベルが少なくとも1ビットで一致する場合、結果はゼロより大きくなり、アクションを続行できます。 それ以外の場合は、HTTPコード403が返されます。

次に、認証ミドルウェアを/users/routes.config.jsのユーザーのモジュールルートに追加する必要があります。

 app.post('/users', [ UsersController.insert ]); app.get('/users', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(PAID), UsersController.list ]); app.get('/users/:userId', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(FREE), PermissionMiddleware.onlySameUserOrAdminCanDoThisAction, UsersController.getById ]); app.patch('/users/:userId', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(FREE), PermissionMiddleware.onlySameUserOrAdminCanDoThisAction, UsersController.patchById ]); app.delete('/users/:userId', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(ADMIN), UsersController.removeById ]);

これで、RESTAPIの基本的な開発は終わりです。 やらなければならないことは、それをすべてテストすることだけです。

不眠症の実行とテスト

Insomniaは、優れた無料バージョンを備えたまともなRESTクライアントです。 もちろん、ベストプラクティスは、コードテストを含め、プロジェクトに適切なエラーレポートを実装することですが、サードパーティのRESTクライアントは、エラーレポートとサービスのデバッグが利用できない場合に、サードパーティのソリューションをテストおよび実装するのに最適です。 ここでは、これを使用してアプリケーションの役割を果たし、APIで何が起こっているかについての洞察を得ます。

ユーザーを作成するには、必要なフィールドを適切なエンドポイントにPOSTし、生成されたIDを後で使用できるように保存する必要があります。

ユーザーを作成するための適切なデータを要求する

APIはユーザーIDで応答します:

userIDによる確認応答

これで、 /auth/エンドポイントを使用してJWTを生成できます。

ログインデータを使用したリクエスト

応答としてトークンを取得する必要があります。

対応するJSONWebトークンを含む確認

accessTokenを取得し、プレフィックスとしてBearer (スペースを覚えておいてください)を付け、 Authorizationの下のリクエストヘッダーに追加します。

転送するヘッダーの設定には、認証JWTが含まれます

パーミッションミドルウェアを実装したのでこれを行わないと、登録以外のすべてのリクエストはHTTPコード401を返します。ただし、有効なトークンが設定されていると、 /users/:userIdから次の応答が返されます。

示されたユーザーのデータをリストする応答

また、前述のとおり、教育目的およびわかりやすくするために、すべてのフィールドを表示しています。 パスワード(ハッシュまたはその他)は、応答に表示されないようにする必要があります。

ユーザーのリストを取得してみましょう。

すべてのユーザーのリストをリクエストする

サプライズ! 403応答を受け取ります。

適切な許可レベルがないため、アクションが拒否されました

ユーザーには、このエンドポイントにアクセスするための権限がありません。 ユーザーのpermissionLevelを1から7に変更する必要があります(無料と有料のアクセス許可レベルはそれぞれ1と4で表されるため、5でもかまいません)。これは、MongoDBのインタラクティブプロンプトで手動で行うことができます。 、このように(IDをローカル結果に変更して):

 db.users.update({"_id" : ObjectId("5b02c5c84817bf28049e58a3")},{$set:{"permissionLevel":5}})

次に、新しいJWTを生成する必要があります。

それが行われた後、適切な応答が得られます。

すべてのユーザーとそのデータへの対応

次に、いくつかのフィールドを含むPATCHリクエストを/users/:userIdエンドポイントに送信して、更新機能をテストしましょう。

更新する部分データを含むリクエスト

操作が成功したことの確認として204の応答が期待されますが、ユーザーにもう一度確認を要求することができます。

変更が成功した後の応答

最後に、ユーザーを削除する必要があります。 上記のように新しいユーザーを作成し(ユーザーIDを忘れないでください)、管理者ユーザーに適切なJWTがあることを確認する必要があります。 新しいユーザーが削除操作を実行できるようにするには、権限を2053(2048- ADMINおよび以前の5)に設定する必要があります。 これが完了し、新しいJWTが生成されたら、 Authorizationリクエストヘッダーを更新する必要があります。

ユーザーを削除するためのセットアップをリクエストする

DELETEリクエストを/users/:userIdに送信すると、確認として204応答が返されます。 繰り返しになりますが、 /users/に既存のすべてのユーザーを一覧表示するように要求することで、確認できます。

RESTAPIの次のステップ

このチュートリアルで説明するツールとメソッドを使用すると、Node.jsでシンプルで安全なRESTAPIを作成できるようになります。 プロセスに不可欠ではない多くのベストプラクティスがスキップされたため、次のことを忘れないでください。

  • 適切な検証を実装します(たとえば、ユーザーの電子メールが一意であることを確認します)
  • ユニットテストとエラーレポートを実装する
  • ユーザーが自分の権限レベルを変更できないようにする
  • 管理者が自分自身を削除できないようにする
  • 機密情報(ハッシュ化されたパスワードなど)の開示を防止する
  • JWTシークレットをcommon/config/env.config.jsからオフレポの非環境ベースのシークレット配布メカニズムに移動します

読者にとっての最後の演習の1つは、コードベースをJavaScriptPromiseの使用からasync/awaitテクニックに変換することです。

興味があるかもしれない人のために、プロジェクトのTypeScriptバージョンも利用できるようになりました。

関連: REST仕様でこれまでに行ったことのない5つのこと