masalibの日記

システム開発、運用と猫の写真ブログです

DenoでJWTをやってみる(MongoDB)

Node.jsの認証はJWT(JSON Web Token)だったのでDenoも同じようにできるみたいなのでやってみる。認証用のDBはMongoDBになります。

masalib.hatenablog.com

前回のMongoDBに認証を加える形になっています。認証とデータをおこなうために毎回、MongoDBの設定を書くのはおかしいのでMongoDBの接続などを外だししました。

前提

このプログラムはログインと認証をおこなっているがユーザーの作成はやっていない。 ユーザー情報を作成する時にはハッシュ化してパスワードが必要になる。以下で作成できる

import * as bcrypt from "https://deno.land/x/bcrypt/mod.ts";
const hash = await bcrypt.hash("password");
console.log(hash)

実行

$ deno run --allow-net --unstable hash.ts

$2a$10$PJ5sSNKKCWnEXJgcmk6Xou7F76G3WqzKYjXSFn1G.O.Ww/U.oeCPC

JWTとは

JWTはJSON Web Tokenの略で、特徴は下記の2点です。

  • コンパクト:tokenという形でURLのクエリパラメーターやPOSTのパラメーター、HTTP Headerに含む事ができる等、非常に小さいサイズで情報をやり取りすることができます。
  • 自己完結:必要な情報を全て、tokenという形で含めることができます。

JWTは3つの要素から成り立ちます。

  • Header
  • Payload
  • Signature

この要素を.で繋ぐことにより1つのtokenとして扱います

詳しくは

jwt.io

を見てほしいです

プログラム構成

├─routes.ts (ルーティング用)
├─server.ts (mainの起動プログラム)
├─denon.json (denonという監視プログラムの設定ファイル)
├─.env (DBの接続情報)
├─mongodb.ts (MongoDBの接続プログラム)
│
└─controllers
  ├─ auth.ts (ユーザーの認証)
  └─ products.ts(productsのデータ取得プログラム)

全プログラムを見たい場合はgithubを参照してください

github.com

事前作業

denon install

変更時に自動的に再起動してくれるツールのインストール。環境設定とかも楽です。

deno install --allow-read --allow-run --allow-write -f --unstable https://deno.land/x/denon/denon.ts

実行

denon start

Routes

//認証なし

GET      /api/v1/products
GET      /api/v1/products/:id
POST     /api/v1/products
PUT      /api/v1/products/:id
DELETE   /api/v1/products/:id

//認証
POST     /login
POST     /auth

//認証あり
GET      /api/v2/products
GET      /api/v2/products/:id
POST     /api/v2/products
PUT      /api/v2/products/:id
DELETE   /api/v2/products/:id

認証する場合はAuthorization: BearerというヘッダーにJWTのトークンをセットします。

ソース解説

server.ts

import {Application,Router} from "https://deno.land/x/oak/mod.ts";
import router from './routes.ts'

const port =  5000 
const app = new Application()
app.use(router.routes())
app.use(router.allowedMethods())
console.log(`Server Running on port ${port}`)

await app.listen({port })

起動用のプログラムなのでルーティング用のファイルを読んでいて適用させているだけです。

mongodb.ts

import { config } from "https://deno.land/x/dotenv/mod.ts";
import { MongoClient } from "https://deno.land/x/mongo@v0.7.0/mod.ts";

//環境変数
const ENV_PATH = '.env';//deno runしたところをカレントディレクトリになるみたい
const config_env:any = config({ path: ENV_PATH });
const user:string = config_env.mongo_user
const password_data:string = config_env.mongo_password
const host:string = config_env.mongo_host
const port_num:number = config_env.mongo_port
const db_name:string = config_env.mongo_db
//mongodb://<dbuser>:<dbpassword>@<host>:<port>/<db>
const url:string = 'mongodb://' + user + ':' + password_data + '@' + host + ':' + port_num + '/' + db_name

//mongoDB接続
console.log("mongodb connection start")
const client_mongodb = new MongoClient()
client_mongodb.connectWithUri(url)

//DBの設定
const mongodb = client_mongodb.database(db_name)

export default mongodb;
  • MongoDBの接続用のプログラムでexportする事で他のプログラムでも使えるようにしています。
  • コレクションは使う前に設定する方式にしています

denon.json

{
  "$schema": "https://deno.land/x/denon/schema.json",
  "scripts": {
    "start": {
      "cmd": "deno run server.ts",
      "allow": [
        "env",
        "read",
        "write",
        "net",
        "plugin"
      ],
      "unstable": true
    }
  },
  "logger": {
    "debug": true
  },
  "watcher": {
    "interval": 350,
    "exts": ["js", "ts", "json"],
    "match": ["*.*"],
    "skip": ["*/.git/*" ,"README.md" ],
    "legacy": false
  }
}
  • 起動時の設定や監視ファイルの無視ファイルを設定しています。allowの設定とか多くて困る・・・この設定があると本当に楽~♪

auth.ts

長いの分割になります。全文をみたい場合はgithubのソースを参照してください。

ライブラリーの読み込み

import { config } from "https://deno.land/x/dotenv/mod.ts";
import { makeJwt, setExpiration, Jose, Payload } from "https://deno.land/x/djwt/create.ts"
import { validateJwt } from "https://deno.land/x/djwt/validate.ts"
import { Context } from "https://deno.land/x/oak/mod.ts";
import * as bcrypt from "https://deno.land/x/bcrypt/mod.ts";
import  mongodb  from '../mongodb.ts'

DBの設定と環境変数の設定

//DBの設定
const db = mongodb

//環境変数
const ENV_PATH = '.env';//deno runしたところをカレントディレクトリになるみたい
const config_env:any = config({ path: ENV_PATH });
const collection_name:string = config_env.mongo_users_collection
const datas = db.collection(collection_name)


const key = "xxxxxxsome-random-secret-keyxxxxxxxxx";
const header: Jose = {
  alg: "HS256",
  typ: "JWT",
}
  • DBの接続を取得してそれをもとにコレクションに接続しています
  • Keyはサーバサイド側でしか知らないsecret keyになります。これも普通は環境変数とかに入れると思う。

  • Headerは最低限下記の2つの要素から成り立ちます。

    • algはhash化する際に利用するアルゴリズムを指定します。
    • typ:トークンのタイプを指定します。通常はJWT

ログイン

// @desc    login 
// @route   POST /login
const login = async ({ request, response }: { request: any , response: any }) => {
  if (!request.hasBody){
    response.status = 400
    response.body = {
      success: false,
      msg: 'No Data'
    } 
  }else {
    const body = await request.body()
    //console.log(body)
    //console.log(body.value.username)
    //console.log(body.value.password)

    //const hash = await bcrypt.hash(body.value.password);
    //console.log(hash)

    const data = await datas.find({ "username": body.value.username });
    //判定が微妙な感じ・・・
    if ( data.toString() ===""){
      console.log("mongodb no data")
      response.status = 422;
      response.body = {
        message: 'Invalid username or password'
      };
    } else {
      //ループでもってくる以外のやり方がわからずこの形になった
      for (var it in data)
      {
        const item = data[it]
        var db_password =  item.password
        var db_id =  item.id
      }
  
      if (await bcrypt.compare(body.value.password, db_password)) {
        const payload: Payload = {
        iss: body.value.username,
        exp: setExpiration(new Date().getTime() + 3600000),
        }
        // Create JWT and send it to user
        const jwt = makeJwt({key, header, payload});
        if (jwt) {
          response.status = 200;
          response.body = {
            id: db_id,
            username: body.value.username,
            jwt,
          }
        } else {
          response.status = 500;
          response.body = {
            message: 'Internal server error'
          }
        }
        return;
      } else {
        console.log("password compare false")
        response.status = 422;
        response.body = {
          message: 'Invalid username or password'
        };
      }
    }
  }  
}
  • request.hasBodyはデータの有無をチェックしています。
  • data = await datas.find({ "username": body.value.username }) はMongoDBからUser情報を取得しています。
  • お恥ずかしいのですがMongoDBからデータを取得して変数にいれる方法がわからず・・・・ループで取得するという形にしています。たぶん違う
      for (var it in data)
      {
        const item = data[it]
        var db_password =  item.password
        var db_id =  item.id
      }
  • await bcrypt.compare(body.value.password, user.password)は入力されたパスワードとハッシュ化されたパスワードが一致(compare)しているのかチェックしています。
  • Payloadはサーバサイドとクライアントサイドで共有したい情報になります。今回はユーザー情報と有効時間です
          const payload: Payload = {
          iss: user.username,
          exp: setExpiration(new Date().getTime() + 3600000),
        }
  • const jwt = makeJwt({key, header, payload});はシークレットキーとヘッダーとペイロードをもとにJWTを作成しています。ちなみにこの変数はお決まりらしくてkeyをSecretKeyに変更するとエラーになりました。
  • リクエストデータのJSONのサンプルは以下の形です
{
   "username": "masalib",
    "password": "password"
}

認証

2つのプログラムで構成されています。authに関しては実はなんでもいいです。 2つに分かれている理由としては認証する部分は他のプログラムでも使われるので分けています。これは次にやる予定のMongoDBでの認証で記載します

// @desc    auth
// @route   POST /auth
const auth = async ({ request, response }: { request: any , response: any }) => {
  response.status = 200
  response.body = {
    success: true,
    data:"auth success"
  }
}


const authMiddleware = async (ctx: Context, next: any) => {

  const headers: Headers = ctx.request.headers;
  // Taking JWT from Authorization header and comparing if it is valid JWT token, if YES - we continue, 
  // otherwise we return with status code 401
  const authorization = headers.get('Authorization')
  console.log("authMiddleware authorization:" + authorization)

  if (!authorization) {
    console.log("authorization Noting" )
    ctx.response.status = 401;
    return;
  }
  const jwt = authorization.split(' ')[1];
  console.log("jwt:" + jwt )
  if (!jwt) {
    console.log("jwt Noting" )
    ctx.response.status = 401;
    return;
  }

  if (await validateJwt(jwt, key, {isThrowing: false})){
    await next();
    return;
  }
  console.log("validateJwt false" )

  ctx.response.status = 401;
  ctx.response.body = {message: 'Invalid jwt token'};
}
  • 認証はヘッダーにあるという形にしています。POSTの中にいれてもいいと思う。

curl --location --request POST 'http://localhost:5000/auth' \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJtYXNhbGliIiwiZXhwIjoxNTkxNTIyODM3NTgwfQ.-UNfi94e2kzuMq9BlIiwVww2masIiF5ax0fgCyW3oPM' \
--data-raw ''
  • const jwt = authorization.split(' ')[1];は0番は違うデータが入っているので無視しています。
  • if (await validateJwt(jwt, key, {isThrowing: false})){ でJWTの認証をおこなっています。

products.ts

//DBの設定
const db = mongodb

//コレクションの設定
const ENV_PATH = '.env';//deno runしたところをカレントディレクトリになるみたい
const config_env:any = config({ path: ENV_PATH });
const collection_name:string = config_env.mongo_products_collection
const datas = db.collection(collection_name)

DBコレクションの設定の部分以外は下記の記事と同じです

masalib.hatenablog.com

感想

  • MongoDBから取得して変数にいれる所が間違えていると思うが動くのはできた。
  • 認証付きのデータ取得が思いのほか簡単にできた。認証はアプリでは一般的に普通にあるので今後に使えそう~♪