masalibの日記

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

DenoでJWTをやってみる

Node.jsの認証はJWT(JSON Web Token)だったのでDenoも同じようにできるみたいなのでやってみる。今回はユーザー情報はハードコード(マジックナンバー)になっている。次ぐらいには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という監視プログラムの設定ファイル)
├─users.ts (ユーザー情報)
│
└─controllers
  └─ products.ts(DBのやり方のプログラム)

事前作業

denon install

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

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

実行

denon start

Routes

POST      /login
POST      /auth

ソース解説

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 })

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

routes.ts

import {Router} from "https://deno.land/x/oak/mod.ts";
import {login,auth,authMiddleware} from './controllers/auth.ts'

const router = new Router()
router.post('/login', login )
        .post('/auth', authMiddleware,auth )

export default router
  • ルーティング用のプログラムで振るまい(コントローラー)の設定をしています
  • .post('/auth', authMiddleware,auth )は他のルーティング処理と違い、authMiddlewareに渡してそれからauthにわたす形になっています。

users.ts

export interface User {
    id: string;
    username: string;
    password: string;
  }
  
  const users: Array<User> = [
    {
      id: "1",
      username: "masalib",
      password: "$2a$10$OBozWmEbXvfs1l085445ieNRYzVIoO.11G1wWJPleTXvIe/1FWSZu",
    },
    {
      id: "2",
      username: "hirano",
      password: "$2a$10$9hC4PDfVUNRPXDzFS1W1NObKjkzgmgcDi8t6kMT/EGmb1yLvkCA6S",
    },
  ]
  
  export default users;

ユーザーの情報をハードコード(マジックナンバー)で記載しています。 普通はDBなどにいきますが今回はそこまでやりません

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

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

DenoでJWTの認証(ブログ説明用のソース)

ライブラリーの読み込み

import users from "../users.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";

JWTの設定

//環境変数
const key = "xxxxxxsome-random-secret-keyxxxxxxxxx";
const header: Jose = {
  alg: "HS256",
  typ: "JWT",
}

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)

    for (const user of users) {
      //if (body.value.username === user.username && body.value.password === user.password) {
      if (body.value.username === user.username && await bcrypt.compare(body.value.password, user.password)) {
          const payload: Payload = {
          iss: user.username,
          //exp: setExpiration(new Date().getTime() + 60000), //サンプルだと60秒だった。これは短いので伸ばす
          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: user.id,
            username: user.username,
            jwt,
          }
        } else {
          response.status = 500;
          response.body = {
            message: 'Internal server error'
          }
        }
        return;
      }
    }
    //認証して失敗した場合
    response.status = 422;
    response.body = {
      message: 'Invalid username or password'
    };
  }  
}
  • request.hasBodyはデータの有無をチェックしています。
  • for (const user of users) {はハードコードしているUser情報を回しています。この部分はDBの参照に切り替わるはずです。
  • 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の認証をおこなっています。

感想

  • 思いのほか簡単にできた。認証はアプリでは一般的に普通にあるので今後に使えそう~♪

参考URL

github.com

qiita.com