シェルスクリプトマガジン

Webアプリケーションの正しい作り方(Vol.64記載)

著者:しょっさん

ソフトウエアを正しく作るために、エンジニアたちはどんなことを知らなければならないでしょうか。実際のコードを使って、より良くしていくためのステップを考えてみましょう。第4回は、3回目のイテレーションを実施し、システムに必要な機能を実装していきます。

シェルスクリプトマガジン Vol.64は以下のリンク先でご購入できます。

図3 マイグレーションファイルのテンプレート

'use strict';

module.exports = {
  up: (queryInterface, Sequelize) => {
    /*
      Add altering commands here.
      Return a promise to correctly handle asynchronicity.

      Example:
      return queryInterface.createTable('users', { id: Sequelize.INTEGER });
    */
  },

  down: (queryInterface, Sequelize) => {
    /*
      Add reverting commands here.
      Return a promise to correctly handle asynchronicity.

      Example:
      return queryInterface.dropTable('users');
    */
  }
};

図4 経費清算のマイグレーションファイルにuser_idのカラムを追加するように修正した

'use strict';

module.exports = {
  up: (queryInterface, Sequelize) => {
    return queryInterface.addColumn('expenses',
      'user_id', {
      type: Sequelize.UUID,
      foreignKey: true,
      references: {
        model: 'users',
        key: 'id',
      },
      onUpdate: 'RESTRICT',
      onDelete: 'RESTRICT',
    }
    );
  },

  down: (queryInterface, Sequelize) => {
    return queryInterface.removeCulumn('expenses',
      user_id);
  }
};

図5 config/database.tsファイル

import { Sequelize } from 'sequelize';

export default function (): Sequelize {
  const env: string = process.env.NODE_ENV || 'development';
  const config: any = require('./config.json')[env];

  if (config.use_env_variable) {
    const config_url: any = process.env[config.use_env_variable];
    return new Sequelize(config_url, config);
  } else {
    return new Sequelize(config.database, config.username, config.password, config);
  }
}

図6 権限テーブルのモデルファイル

import { Sequelize, Model, DataTypes } from 'sequelize';
import * as config from '../config/database';

const sequelize: Sequelize = config.default();

class Role extends Model {
  public id!: number;
  public user_id!: string;
  public name!: string;
  public readonly careated_at!: Date;
  public readonly updated_at!: Date;
}

Role.init({
  id: {
    type: DataTypes.INTEGER,
    autoIncrement: true,
    allowNull: false,
    primaryKey: true,
  },
  user_id: {
    type: DataTypes.UUID,
    allowNull: false,
    validate: {
      isUUID: 4
    }
  },
  name: {
    type: DataTypes.STRING(128),
    allowNull: false,
    defaultValue: ''
  }
}, {
  tableName: 'roles',
  underscored: true,
  sequelize: sequelize
});

export { Role };

図7 ユーザーマスターテーブルのモデルファイル

import { Sequelize, Model, DataTypes } from 'sequelize';
import { Expense } from './expense';
import { Role } from './role';
import * as config from '../config/database';

const sequelize: Sequelize = config.default();

class User extends Model {
  public id!: string;
  public boss_id?: string;
  public first_name?: string;
  public last_name!: string;
  public email!: string;
  public hash!: string;
  public deleted_at?: Date;
  public readonly careated_at!: Date;
  public readonly updated_at!: Date;
}

User.init({
  id: {
    type: DataTypes.UUID,
    allowNull: false,
    defaultValue: DataTypes.UUIDV4,
    primaryKey: true,
    validate: {
      isUUID: 4
    }
  },
  boss_id: {
    type: DataTypes.UUID
  },
  first_name: {
    type: DataTypes.STRING(32)
  },
  last_name: {
    type: DataTypes.STRING(32),
    allowNull: false
  },
  email: {
    allowNull: false,
    unique: true,
    type: DataTypes.STRING,
    validate: {
      isEmail: true
    }
  },
  hash: {
    allowNull: false,
    type: DataTypes.STRING(256)
  },
  deleted_at: {
    type: DataTypes.DATE,
    defaultValue: null
  },
}, {
  tableName: 'users',
  underscored: true,
  sequelize: sequelize
});

User.hasMany(Role, {
  sourceKey: 'id',
  foreignKey: 'user_id',
  as: 'roles'
})

User.hasMany(Expense, {
  sourceKey: 'id',
  foreignKey: 'user_id',
  as: 'expenses'
});

User.hasOne(User, {
  sourceKey: 'id',
  foreignKey: 'boss_id',
  as: 'users'
});

export { User };

図8 passportライブラリを使って、パスワード認証を行う部分(index.tsへの追加)

import bcrypt from 'bcrypt';
import passport from 'passport';
import { Strategy as LocalStrategy } from 'passport-local';
import { User } from './models/user';

// passport 初期化
app.use(passport.initialize());
app.use(passport.session());

// passport の認証定義
passport.use(new LocalStrategy({
  usernameField: 'user',
  passwordField: 'password'
}, (username, password, done) => {
  User.findOne({ where: { email: username, deleted_at: null } }).then(user => {
    if (!user || !bcrypt.compareSync(password, user.hash))
      return done(null, false);
    return done(null, user.get());
  })
}));

// passport 認証時のユーザ情報のセッションへの保存やセッションからの読み出し
passport.serializeUser((user: User, done) => {
  return done(null, user);
});
passport.deserializeUser((user: User, done) => {
  User.findByPk(user.id).then(user => {
    if (user) {
      done(null, user.get());
    } else {
      done(false, null);
    }
  })
});

図9 ログインの強制(index.ts への追加)

// ログインの強制
app.use((req, res, next) => {
  if (req.isAuthenticated())
    return next();
  if (req.url === '/' || req.url === '/login')
    return next();
  res.redirect('/login');
});

図10 src/routes/login.tsファイル(ログインスクリプト)

import Express from 'express';
const router = Express.Router();
import passport from 'passport';

// GET /login ユーザーログインフォーム
router.get('/', (req: Express.Request, res: Express.Response): void => {
  res.send('<h1>LOGIN</h1><form action="/login" method="post">ユーザーID:<input type="text" name="user" size="40"><br />パスワード<input type="password" name="password"><input type="submit" value="ログイン"><br /><a href="/login">ログイン</a><br /><a href="/expenses/submit">経費入力</a><br /><a href="/expenses/payment">支払い処理</a>');
});

// POST / ユーザーの認証処理
router.post('/',
  passport.authenticate('local', {
    successRedirect: '/',
    failureRedirect: '/login'
  })
);

export default router;

図11 src/routes/expenses/submit.tsファイルの修正

import Express from ‘express’;
const router = Express.Router();
import { Expense } from '../../models/expense’;

// GET /expenses/submit 入力フォーム
router.post('/', (req: Express.Request, res: Express.Response): void => {
  Expense.create(req.body)
    .then(result => {
      res.redirect(‘/‘);
    });
});

// POST /expenses/submit 経費の申請
router.get('/', (req: Express.Request, res: Express.Response): void => {
  const user = req!.user!.email;
  const id = req!.user!.id;
  res.send(<h2>経費入力</h2><form action="/expenses/submit" method="post"><input type="hidden" name="user_id" value="${id}">申請者名:<input type="text" name="user_name" value="${user}"><br />日付:<input type="date" name="date"><br />経費タイプ:<input type="text" name="type"><br />経費詳細:<input type="text" name="description"><br />金額:<input type="number" name="amount"><br /><input type="submit" value="経費申請"><br /><a href="/login">ログイン</a><br /><a href="/expenses/submit">経費入力</a><br /><a href="/expenses/payment">支払い処理</a>);
});

export default router;

図12 用意した型定義ファイル

interface UserModel {
  id: string;
  boss_id?: string;
  first_name: string;
  last_name: string;
  email: string;
}

declare namespace Express {
  export interface User extends UserModel { }
}

図13 認証関係をつかさどるAuthenticationクラスを記述した「src/controllers/auth/index.ts」ファイル

import bcrypt from 'bcrypt';
import { User } from "../../models/user";
import passport from 'passport';
import { Strategy as LocalStrategy } from 'passport-local';

export class Authentication {
  static initialize(app: any) {
    // passport 初期化
    app.use(passport.initialize());
    app.use(passport.session());

    passport.serializeUser(this.serializeUser);
    passport.deserializeUser(this.deserializeUser);
  }
  static serializeUser(user: any, done: any) {
    return done(null, user);
  }
  static deserializeUser(user: any, done: any) {
    User.findByPk(user.id)
      .then(user => {
        return done(null, user);
      })
      .catch(() => {
        return done(null, false);
      });
  }

  static verify(username: string, password: string, done: any) {
    User.findOne(
      {
        where: {
          email: username
        }
      }
    ).then(user => {
      if (!user || !bcrypt.compareSync(password, user.hash))
        return done(null, false);
      return done(null, user.get());
    });
  }

  static setStrategy() {
    // passport の認証定義
    const field = {
      usernameField: 'user',
      passwordField: 'password'
    };
    passport.use(new LocalStrategy(field, this.verify));
  }
}

図14 認証関係のテストケースを記述した「authentication.test.ts」ファイル

import { Authentication } from '../src/controllers/auth/index';
import { User } from '../src/models/user';

describe('authentication', () => {
  it('serialize', done => {
    const callback = (arg: any, user: User) => {
      expect(user.id).toBe('811FCB5D-7128-4AA6-BFEE-F1A8D3302CDA');
      expect(user.email).toBe('test@example.com');
      expect(arg).toBeNull();
      done();
    }
    const user_sample = {
      id: '811FCB5D-7128-4AA6-BFEE-F1A8D3302CDA',
      last_name: 'test',
      email: 'test@example.com',
    }

    Authentication.serializeUser(user_sample, callback);
  });

  it('deserialize - positive', done => {
    const callback = (arg: any, user: any) => {
      expect(arg).toBeNull();
      expect(user.id).toBe('811FCB5D-7128-4AA6-BFEE-F1A8D3302CDA');
      done();
    }
    const user_sample = {
      id: '811FCB5D-7128-4AA6-BFEE-F1A8D3302CDA',
      last_name: 'test',
      email: 'test@example.com',
    }

    Authentication.deserializeUser(user_sample, callback);
  });
  it('deserialize - negative', done => {
    const callback = (arg: any, user: any) => {
      expect(arg).toBeNull();
      expect(user).toBe(false);
      done();
    }
    const user_sample = {
      id: '',
      last_name: 'test',
      email: 'test@example.com',
    }

    Authentication.deserializeUser(user_sample, callback);
  });

  it('verify - positive', done => {
    const callback = (arg: any, user: any) => {
      expect(arg).toBeNull();
      expect(user.id).toBe('811FCB5D-7128-4AA6-BFEE-F1A8D3302CDA');
      expect(user.email).toBe('test@example.com');
      expect(user.last_name).toBe('test');
      done();
    }

    Authentication.verify('test@example.com', 'password', callback);
  })

  it('verify - negative', done => {
    const callback = (arg: any, user: any) => {
      expect(arg).toBeNull();
      expect(user).toBe(false);
      done();
    }

    Authentication.verify('test@example.com', 'incorrect', callback);
  })

  it('verify - deleted', done => {
    const callback = (arg: any, user: any) => {
      expect(arg).toBeNull();
      expect(user).toBe(false);
      done();
    }

    Authentication.verify('deleted@example.com', 'password', callback);
  })
})

図15 テストデータ作成用のシードファイル(src/seeders/*-demo-user.js)

'use strict';

module.exports = {
  up: (queryInterface, Sequelize) => {
    return queryInterface.bulkInsert('users', [
      {
        id: '811FCB5D-7128-4AA6-BFEE-F1A8D3302CDA',
        email: 'test@example.com',
        last_name: 'test',
        hash: '$2b$10$IPwsYH8cAD9IarEGhj1/Vua2Lz4y/FD7GubAB.dNgfxgqx6i5heyy',
        created_at: new Date(),
        updated_at: new Date()
      },
      {
        id: '326260F7-2516-4C17-B8D1-DE50EF42C440',
        email: 'deleted@example.com',
        last_name: 'deleted',
        hash: '$2b$10$IPwsYH8cAD9IarEGhj1/Vua2Lz4y/FD7GubAB.dNgfxgqx6i5heyy',
        created_at: new Date(),
        updated_at: new Date(),
        deleted_at: new Date()
      }
    ]);
  },

  down: (queryInterface, Sequelize) => {
    return queryInterface.bulkDelete('users', {
      id: [
        '811FCB5D-7128-4AA6-BFEE-F1A8D3302CDA',
        '326260F7-2516-4C17-B8D1-DE50EF42C440'
      ]
    });
  }
};