著者:しょっさん
ソフトウエアを正しく作るために、エンジニアたちはどんなことを知らなければならないでしょうか。実際のコードを使って、より良くしていくためのステップを考えてみましょう。第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'
]
});
}
};