Old Sunset Days

Node.jsでRestify+Sequelizeでユーザー認証

日付 タグ node.js カテゴリ node.js

目次

Restify+Sequelizeでユーザー認証APIサーバーを作る

今回は以前Restifyで作ったAPIサーバーの改善版の話である。Node.jsでRestifyを使ってAPIサーバー構築をする話を以前に書いた。

(==> Node.jsでRestifyを使ってAPIサーバー構築 )

その時はユーザー認証をするサーバーではあるが、Restify自体の説明のためにユーザー情報はDBには保存せず、配列に保存したデータを扱っていた。

今回は改善版ということで、主に以下のモジュールを用いて、ユーザー情報をSQLite3のDBに保存して、ユーザー認証APIサーバーを構築する話である。

  • Restifyユーザー認証サーバー
    • restify
    • sequelize
    • sqlite3
    • bcrypt
    • joi
  • HTTPクライアント
    • superagent

上記モジュールに関する話は以前の記事で書いてるので、基本については過去記事を参照して欲しい。

sequelize & sqlite3
( Node.jsでSequelizeを使ってRDBを扱う )

bcrypt
( Node.jsでbcryptでパスワードをハッシュ化する )

joi
( Node.jsでJoiでパラメータのバリデーション )

superagent
( Node.jsでSuperagentでAPIサーバーにアクセスする )

今回やっていることは、これらをまとめたような内容となっている。

なお、今回作ったサンプルのソースコードは参照用としてGithubの以下に置いてある。 https://github.com/hugodeblog/node-restify-sequelize

必要なパッケージのインストール

今回もまずはサンプルディレクトリの作成とパッケージインストールから

$ mkdir node-restify-sequelize
$ cd node-restify-sequelize
$ npm init --yes
$ npm install --save sequelize
$ npm install --save sqlite3
$ npm install --save js-yaml
$ npm install --save joi
$ npm install --save bcrypt
$ npm install --save superagent

その上で、サーバーとクライアントを別に今回作るのでpackage.json を以下のようにした。

package.json
{
  "name": "node-restify-sequelize",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start-server": "DEBUG=db:*,users:* node ./user-server.mjs",
    "start-client": "node ./agent.mjs"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "bcrypt": "^5.0.0",
    "joi": "^17.3.0",
    "js-yaml": "^3.14.1",
    "restify": "^8.5.1",
    "sequelize": "^6.3.5",
    "sqlite3": "^5.0.0",
    "superagent": "^6.1.0"
  }
}

サーバーとクライアントを起動するためのコマンド指定を入れてある。

DB関連を扱うコード

DBとしてはSQLite3を利用する。接続設定はsequelize-sqlite.yaml にて設定。

sequelize-sqlite.yaml
dbname: users
username:
password:
params:
    dialect: sqlite
    storage: users-sequelize.sqlite3
    logging: false

DBにORM(Sequelize)を用いて接続し、DB中のデータを読み込んだり、追加書き込みするコード。

sequelize-sqlite.yaml
import Sequelize from 'sequelize';
import { default as jsyaml } from 'js-yaml';
import * as util from 'util';
import { promises as fs } from 'fs';
import { default as bcrypt } from 'bcrypt';
const saltRounds = 10;

import DBG from 'debug';
const log = DBG('db:log');

//ハッシュを返す
async function hashpass(password) {
    let salt = await bcrypt.genSalt(saltRounds);
    let hashed = await bcrypt.hash(password, salt);
    return hashed;
}

//パスワードハッシュの比較
async function verifypass(passA, passB) {
  let pwcheck = await bcrypt.compare(passA, passB);
  return pwcheck;
}


class User extends Sequelize.Model {
  // パスワード抜きのユーザーデータにする
  getSanitized() {
    return {"id": this.id, "username": this.username, "address": this.address}
  }
}

let sequlz;

export async function connectDB() {

  if(sequlz) return sequlz;

  const yamltext = await fs.readFile('sequelize-sqlite.yaml', 'utf8');
  const params = await jsyaml.safeLoad(yamltext, 'utf8');

  sequlz = new Sequelize(
    params.dbname,
    params.uesrname,
    params.password,
    params.params
  );

  User.init({
    username: {type: Sequelize.STRING, unique: true},
    password: Sequelize.STRING,
    address: Sequelize.STRING
  }, {
    sequelize: sequlz,
    modelName: 'User',
    timestamps: true
  });

  await User.sync({ force: true });
}


export async function closeDB() {

  if (sequlz) sequlz.close();
  sequlz = undefined;

}

export async function createUser(user, pass, address) {
   await connectDB();
   const hashedPass = await hashpass(pass);

   // 同じユーザーがいるかどうかチェックする
   var result = await User.findOne( {where:{username:user} });
   if(!result) {
     // ok
   } else {
     throw new Error(`Same username not allowed for ${user}`);
   }

   result = await User.create( {
     username:user, password:hashedPass, address:address
   });
   return result.getSanitized();
}

export async function readUser(user_id) {
  await connectDB();
  var result = await User.findOne( {where:{id:user_id} });
  if(!result) {
    throw new Error(`Not found for id:${user_id}`);
  } else {
    return result.getSanitized();
  }
}

export async function updateUser(user_id, user, pass, address) {
  await connectDB();
  const hashedPass = await hashpass(pass);
  var result = await User.findOne( {where:{id:user_id} });
  if(!result) {
    throw new Error(`Not found for id:${user_id}`);
  } else {
    await User.update(
      {username:user, password:hashedPass, address:address},
      {where:{id:user_id}}
    );
    result = await User.findOne( {where:{id:user_id} });
    return result.getSanitized();
  }
}

export async function destroyUser(user_id) {
  await connectDB();
  var result = await User.findOne( {where:{id:user_id} });
  if(!result) {
    throw new Error(`Not found for id:${user_id}`);
  } else {
    await User.destroy( {where:{id:user_id} });
  }
}

export async function readAllUsers() {
  await connectDB();
  const results = await User.findAll({});
  if(!results) {
    throw new Error('Read all user failed');
  } else {
    return results.map(result => result.getSanitized());
  }
}

export async function passCheck(user, pass) {
   await connectDB();
   var result = await User.findOne( {where:{username:user} });
   if(!result) {
     throw new Error(`Not found for ${user}`);
   } else {
     let passOK = await verifypass(pass, result.password);
     log(`passcheck => ${pass}:${result.password}:${passOK}`);
     return passOK;
   }
}

ほとんど今までの記事で扱った内容と近いが、

User.init({
  username: {type: Sequelize.STRING, unique: true},
  password: Sequelize.STRING,
  address: Sequelize.STRING
},

これで実際に作られるDBスキーマを表示してみると、以下の通り。

sqlite> .schema Users
CREATE TABLE `Users` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `username` VARCHAR(255) UNIQUE, `password` VARCHAR(255), `address` VARCHAR(255), `createdAt` DATETIME NOT NULL, `updatedAt` DATETIME NOT NULL);

UserクラスはSequelizeのModelを継承しているが、パスワードを抜いた情報のみ返したい時もあるためにgetSanitized()を定義してある。

class User extends Sequelize.Model {
  // パスワード抜きのユーザーデータにする
  getSanitized() {
    return {"id": this.id, "username": this.username, "address": this.address}
  }
}

他は以前の記事とかぶる内容が多いので説明は割愛させていただく。

Restifyでクライアントからの要求を処理する部分

user-server.mjs
import restify from 'restify';
import * as util from 'util';
import Joi from 'joi';

import {closeDB, createUser, updateUser, readUser, destroyUser,readAllUsers, passCheck} from './sequelize.mjs';

import DBG from 'debug';
const log = DBG('users:log');

// RESTサーバーセットアップ
var server = restify.createServer({
  name: "Rest-API-Test",
  version: "0.0.2"
});

server.use(restify.plugins.authorizationParser());
server.use(check);
server.use(restify.plugins.queryParser());
server.use(restify.plugins.bodyParser({mapParams: true}));

server.listen(4000, "localhost", function() {
  log(server.name + ' listening at ' + server.url);
})

process.on('uncaughtException', function(err) {
  console.error('UNCAUGHT EXCEPTION: ' + (err.stack || err));
  process.exit(1);
});

process.on('unhandledRejection', (reason, p) => {
  console.error('UNHANDLED PROMISE REJECTION: ' + util.inspect(p) + ' reason: ' + reason);
  process.exit(1);
});

function catchProcessDeath() {
  log('shutdown ...');
  process.exit(0);
}

process.on('SIGTERM', catchProcessDeath);
process.on('SIGINT', catchProcessDeath);
process.on('SIGHUP', catchProcessDeath);

process.on('exit', () => {
  closeDB();
  log('exiting...');
});

// Basic認証用のためのuser, password
const BasicAuthKey = {
   username: process.env.BASIC_AUTH_USER,
   password: process.env.BASIC_AUTH_PASS
 };

function check(req, res, next) {

  log('basic authorization check was called');
  if(req.authorization && req.authorization.basic) {
    if(req.authorization.basic.username === BasicAuthKey.username
      && req.authorization.basic.password === BasicAuthKey.password) {
      log('basic authorization OK');
      next();
    }
    else {
      res.contentType = 'json';
      res.send(401, 'Not authorized');
      next(false);
    }
  }
  else {
    res.contentType = 'json';
    res.send(500, 'No authorization key');
    next(false);
  }
}

// パラメータ用のスキーマ
const schema = Joi.object().keys({
  username: Joi.string().alphanum().min(6).max(16).required(),
  password: Joi.string().regex(/^[a-zA-Z_0-9]{8,30}$/).required()
})

// スキーマチェックエラータイプ
class SchemaError extends Error {
  constructor(...args) {
    super(...args)
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, SchemaError)
    }
    this.name = "Shema Check Error";
  }
}

// パラメータのスキーマチェック
function checkSchema(username, password) {
  log('checkSchema called');
  var {error, value} = schema.validate({"username":username, "password":password}, { abortEarly: false })
  if(error){
    log(error.details);
    throw new SchemaError("schema check failed");
  } else {
    log(value);
  }
}

// デバッグテスト用
function timeoutTest(value){
  return new Promise(function(resolve,reject){
    setTimeout(function(){
    console.log(`myFuncの実行中:引数${value}`);
    console.log(`${value}ms待ち完了`);
    console.log('=======');
    resolve('処理待ちしたよ');
    }, value);
  });
}

// ユーザー作成
server.post('/users', async(req, res, next) => {

  // Timeoutをテストする場合
  // const result1 = await timeoutTest(7000);

  log('==> post /users called');
  log(`req.params.username = ${req.params.username}`);
  log(`req.params.password = ${req.params.password}`);
  log(`req.params.address = ${req.params.address}`);
  try {
    checkSchema(req.params.username, req.params.password);
    let result = await createUser(req.params.username, req.params.password, req.params.address);
    res.contentType = 'json';
    res.send(Object.assign(result, {'message': 'create user: ok'}));
  } catch(err) {
    res.send(500, err.message);
    next(false);
  }
});

// ユーザー参照
server.get('/users/:id', async(req, res, next) => {
  log('==> get /users/:id called');
  log(`req.params.id = ${req.params.id}`);
  try {
    let result = await readUser(req.params.id);
    res.contentType = 'json';
    res.send(Object.assign(result, {'message': 'find user: ok'}));
  } catch(err) {
    res.send(500, err.message);
    next(false);
  }
});

// ユーザー削除
server.del('/users/:id', async(req, res, next) => {
  log('==> del /users/:id called');
  log(`req.params.id = ${req.params.id}`);
  try {
    await destroyUser(req.params.id);
    res.contentType = 'json';
    res.send({'message': 'delete user: ok'});
  } catch(err) {
    res.send(500, err.message);
    next(false);
  }
});

// ユーザー一覧取得
server.get('/users', async(req, res, next) => {
  log('==> get /users called');
  try {
    let result = await readAllUsers();
    res.contentType = 'json';
    res.send(Object.assign(result, {'message': 'create user: ok'}));
  } catch(err) {
    res.send(500, err.message);
    next(false);
  }
});

// ユーザー情報更新
server.put('/users/:id', async(req, res, next) => {
  log('==> put /users/:id called');
  log(`req.params.id = ${req.params.id}`);
  log(`req.params.username = ${req.params.username}`);
  log(`req.params.password = ${req.params.password}`);
  log(`req.params.username = ${req.params.address}`);
  try {
    checkSchema(req.params.username, req.params.password);
    let result = await updateUser(req.params.id, req.params.username, req.params.password, req.params.address);
    res.contentType = 'json';
    res.send(Object.assign(result, {'message': 'update user: ok'}));
  } catch(err) {
    res.send(500, err.message);
    next(false);
  }
});

// ユーザー、パスワードチェック
server.post('/password-check', async(req, res, next) => {
  log('==> put /password-check called');
  log(`req.params.username = ${req.params.username}`);
  log(`req.params.password = ${req.params.password}`);
  try {
    const result = await passCheck(req.params.username, req.params.password);
    log(`${result}`);
    if(result) {
      res.contentType = 'json';
      res.send({'message': 'auth check: ok'});
    }
    else {
      res.send(401, 'auth check: incorrect');
      next(false);
    }
  } catch(err) {
    res.send(500, err.message);
    next(false);
  }
});

基本的にはパスワード整合性チェック以外はREST APIを意識したAPIとなっている。

メソッド  URL 役割
GET http://localhost:4000/users ユーザーの一覧取得
POST http://localhost:4000/users ユーザーを新規追加
GET http://localhost:4000/users/:id 特定のユーザー情報を取得
UPDATE http://localhost:4000/users/:id 特定のユーザー情報を更新
DELETE http://localhost:4000/users/:id 特定のユーザーを削除  
POST http://localhost:4000/password-check パスワード整合性チェック 

他は以前に書いた記事と似たような作りだが、Joiを利用したパラメータのバリデーションでエラーが出た場合は、カスタムエラーとして以下を定義している。

// スキーマチェックエラータイプ
class SchemaError extends Error {
  constructor(...args) {
    super(...args)
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, SchemaError)
    }
    this.name = "Shema Check Error";
  }
}

その他、以前の記事で書いたようにbcryptを使ってパスワードはハッシュ化してDBに保存しているため、パスワードの一致性はbcryptのcompareを利用する必要があることには注意である。

Superagentによるクライアントのコード

HTTPクライアントのコードは、前回JASON Serverを用いてテストした時と同じように、呼び出すAPIのURIをsuperagentに渡して、返り値が正常かどうかをチェックすれば良い。

agent.mjs
import {default as request} from 'superagent';
import * as util from 'util';
import * as url from 'url';
const URL = url.URL;
import DBG from 'debug';
const log = DBG('agent:log');

const BasicAuthKey = {
  user: process.env.BASIC_AUTH_USER,
  pass: process.env.BASIC_AUTH_PASS
};

function requestURL(path) {
  const requrl = new URL('http://localhost:4000');
  requrl.pathname = path;
  return requrl.toString();
}

async function create(username, password, address) {
  try {
    var res = await request.post(requestURL('/users'))
    .timeout({response: 5*1000, deadline: 10*1000})
    .send({'username':username, 'password':password, 'address':address})
    .set('Content-Type', 'application/json')
    .auth(BasicAuthKey.user, BasicAuthKey.pass);
    return res.body;
  } catch(err) {
    if(err.response && err.response.status && err.response.body)
      throw new Error(`stauts=>${err.response.status}, message=>${err.response.body}`);
    else
      throw new Error(err);
  }
}

async function read(id) {
  try {
    var res = await request.get(requestURL(`/users/${id}`))
    .timeout({response: 5*1000, deadline: 10*1000})
    .send()
    .set('Content-Type', 'application/json')
    .auth(BasicAuthKey.user, BasicAuthKey.pass);
    return res.body;
  } catch(err) {
    if(err.response && err.response.status && err.response.body)
      throw new Error(`stauts=>${err.response.status}, message=>${err.response.body}`);
    else
      throw new Error(err);
  }
}

async function update(id, username, password, address) {
  try {
    var res = await request.put(requestURL(`/users/${id}`))
    .timeout({response: 5*1000, deadline: 10*1000})
    .send({'username':username, 'password':password, 'address':address})
    .set('Content-Type', 'application/json')
    .auth(BasicAuthKey.user, BasicAuthKey.pass);
    return res.body;
  } catch(err) {
    if(err.response && err.response.status && err.response.body)
      throw new Error(`stauts=>${err.response.status}, message=>${err.response.body}`);
    else
      throw new Error(err);
  }
}

async function destroy(id) {
  try {
    var res = await request.delete(requestURL(`/users/${id}`))
    .timeout({response: 5*1000, deadline: 10*1000})
    .send()
    .set('Content-Type', 'application/json')
    .auth(BasicAuthKey.user, BasicAuthKey.pass);
    return res.body;
  } catch(err) {
    if(err.response && err.response.status && err.response.body)
      throw new Error(`stauts=>${err.response.status}, message=>${err.response.body}`);
    else
      throw new Error(err);
  }
}

async function pass(username, password) {
  try {
    var res = await request.post(requestURL('/password-check'))
    .timeout({response: 5*1000, deadline: 10*1000})
    .send({'username':username, 'password':password})
    .set('Content-Type', 'application/json')
    .auth(BasicAuthKey.user, BasicAuthKey.pass);
    return res.body;
  } catch(err) {
    if(err.response && err.response.status && err.response.body)
      throw new Error(`stauts=>${err.response.status}, message=>${err.response.body}`);
    else
      throw new Error(err);
  }
}

async function readAll(id) {
  try {
    var res = await request.get(requestURL('/users'))
    .timeout({response: 5*1000, deadline: 10*1000})
    .send()
    .set('Content-Type', 'application/json')
    .auth(BasicAuthKey.user, BasicAuthKey.pass);
    return res.body;
  } catch(err) {
    if(err.response && err.response.status && err.response.body)
      throw new Error(`stauts=>${err.response.status}, message=>${err.response.body}`);
    else
      throw new Error(err);
  }
}

(async () => {

  try {

    console.log('------ read all users ------');
    var result = await readAll();
    console.log(result);

    console.log('------ create user1 ------');
    var result = await create('sssmeme', '123ffffdadfaf', 'Tokyo');
    console.log(result);
    let userID1 = result.id;

    console.log('------ create user2 ------');
    var result = await create('ddbfafda', 'ifdafdallll', 'Tokyo');
    //var result = await create('ddbfafda', 'i', 'Tokyo');
    console.log(result);

    console.log('------ read all users ------');
    var result = await readAll();
    console.log(result);

    console.log('------ read user1 ------');
    result = await read(userID1);
    console.log(result);

    console.log('------ update user1 ------');
    result = await update(userID1, 'sssmeme', '123ffffdadfaf', 'Kagawa');
    console.log(result);

    console.log('------ passcheck user1 ------');
    result = await pass('sssmeme', '123ffffdadfaf');
    console.log(result);

    //console.log('------ passcheck wrong user1 ------');
    //result = await pass('sssmeme', '333ffffdadfaf');
    //console.log(result);

    console.log('------ delete user1 ------');
    result = await destroy(userID1);
    console.log(result);

    console.log('------ read user1 ------');
    result = await read(userID1);
    console.log(result);

  } catch(err) {
    console.error(err.message);
  }

})();

サンプルコードの実行確認

まずはRestifyで作ったユーザー認証サーバーを起動させておく。

$ BASIC_AUTH_USER=test BASIC_AUTH_PASS=password npm run start-server

> node-restify-sequelize@1.0.0 start-server
> DEBUG=db:*,users:* node ./user-server.mjs

サーバーが起動したのを確認したら、superagentを用いたHTTPクライアントで、サーバーに対してユーザー認証関連のAPIを投げてみる。

$ BASIC_AUTH_USER=test BASIC_AUTH_PASS=password npm run start-client

> node-restify-sequelize@1.0.0 start-client
> node ./agent.mjs

------ read all users ------
[]
------ create user1 ------
{
  id: 1,
  username: 'sssmeme',
  address: 'Tokyo',
  message: 'create user: ok'
}
------ create user2 ------
{
  id: 2,
  username: 'ddbfafda',
  address: 'Tokyo',
  message: 'create user: ok'
}
------ read all users ------
[
  { id: 1, username: 'sssmeme', address: 'Tokyo' },
  { id: 2, username: 'ddbfafda', address: 'Tokyo' }
]
------ read user1 ------
{
  id: 1,
  username: 'sssmeme',
  address: 'Tokyo',
  message: 'find user: ok'
}
------ update user1 ------
{
  id: 1,
  username: 'sssmeme',
  address: 'Kagawa',
  message: 'update user: ok'
}
------ passcheck user1 ------
{ message: 'auth check: ok' }
------ delete user1 ------
{ message: 'delete user: ok' }
------ read user1 ------
stauts=>500, message=>Not found for id:1

最後はエラーで終わっているが、

stauts=>500, message=>Not found for id:1

これはユーザー1を削除した後に情報を見に行っているので、想定通りのエラー出力である。

以上、かけ足的ではあるが、今まで学んできたモジュール、パッケージ等を利用してDBにユーザー情報を保存するユーザー認証APIサーバーをサンプルとして構築してみた。