著者:しょっさん
ソフトウエアを正しく作るために、エンジニアたちはどんなことを知らなければならないでしょうか。実際のコードを使って、より良くしていくためのステップを考えてみましょう。第4回は、3回目のイテレーションを実施し、システムに必要な機能を実装していきます。
シェルスクリプトマガジン Vol.64は以下のリンク先でご購入できます。
図3 マイグレーションファイルのテンプレート
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
'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のカラムを追加するように修正した
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
'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ファイル
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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 権限テーブルのモデルファイル
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
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 ユーザーマスターテーブルのモデルファイル
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 |
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への追加)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
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 への追加)
1 2 3 4 5 6 7 8 |
// ログインの強制 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ファイル(ログインスクリプト)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
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ファイルの修正
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
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 用意した型定義ファイル
1 2 3 4 5 6 7 8 9 10 11 |
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」ファイル
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
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」ファイル
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
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)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
'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' ] }); } }; |