N高等学校で生徒をやっておりますshow0317swです。
今回はN高等学校アドベントカレンダー14日目の記事になります。
はじめに
最初は自分が作っているサービスを紹介できたらなぁ。なんて思っていたのですが、予想通り完成してないので、この記事ではそのサービスで利用したメール認証でのサインアップに関する内容にしようと思います。
なお、例外処理や脆弱性対策は十分ではありません。ユーザー情報を扱うので実際に応用して使う場合は、HTTPSでの提供など各自で必要な対策を行ってください。
GitHubに今回のコードを置いておくので、こちらも参考にしてください。
前提
今回の環境は以下の通りです。
開発環境:
- Ubuntu 18.04.1 LTS
- Node.js v10.13.0
ライブラリ:
- express v4.16.4
- sequelize v4.41.2
- pg v7.6.1
- pg-hstore v2.3.2
- passport v0.4.0
- passport-local v1.0.0
- nodemailer v4.7.0
- uuid v3.3.2
#準備
まずは、express-generatorでアプリを生成します。viewはお好きなものを使ってください。今回はpugでつくります。アプリの名前はmail-auth
としました。
$ express --view=pug mail-auth && cd ./mail-auth
次に、プロジェクトに必要なパッケージをインストールします。
$ yarn install && yarn add sequelize pg pg-hstore passport passport-local nodemailer uuid
$ npm i && npm i -S sequelize pg pg-hstore passport passport-local nodemailer uuid
#データモデルの作成
###設計
登録したユーザー情報を管理するためのデータモデルを作成します。
ユーザー情報はuser_tmp
、user
、user_auth
の3つのテーブルに分けました。
以下がそれぞれのテーブルの役割と内容になります。
user_tmp
テーブル: フォームから受け取った情報を保存する。認証が完了したら削除される。
カラム名 | データ型 | 属性 | 内容 |
---|---|---|---|
STRING | PRIMARY KEY | メールアドレス | |
username | STRING | ユーザー名 | |
password | STRING | パスワード | |
token | STRING | UNIQUE | 認証トークン |
user
テーブル: 認証済みユーザーの情報を保存する。
カラム名 | データ型 | 属性 | 内容 |
---|---|---|---|
user_id | UUID | PRIMARY KEY | ユーザーID |
username | STRING | ユーザー名 | |
STRING | メールアドレス | ||
created_at | DATE | 作成日時 | |
updated_at | DATE | 更新日時 | |
deleted_at | DATE | 削除日時 |
user_auth
テーブル: 認証済みユーザーのサインインに使用する。
カラム名 | データ型 | 属性 | 内容 |
---|---|---|---|
username | STRING | PRIMARY KEY | ユーザー名 |
user_id | UUID | FOREIGN KEY | ユーザーID |
password | STRING | パスワード | |
STRING | メールアドレス | ||
created_at | DATE | 作成日時 | |
updated_at | DATE | 更新日時 |
DBの運用は以下のようになります。
- [ユーザー]フォームからユーザー情報を送信。
- [システム]
user_tmp
にユーザー1の情報と新たに生成したトークンをハッシュ化して保存する。 - [システム]2で生成したトークンを含んだURLを生成し1のメールアドレスに送信する。
- [ユーザー]3のURLにアクセスする。
- [システム]URLに含まれるトークンをハッシュ化してDBのトークンと照合する。合致した場合は新たにユーザーIDを生成して
user
テーブルとuser_auth
テーブルにユーザー情報を追加する。user_tmp
テーブルからは物理削除する。
- 認証済みユーザーのサインイン時は
user_auth
テーブルからusernameかemailとpasswordを検索してユーザーを特定する。 - 認証済みユーザーの退会時は該当ユーザーを
user
テーブルから論理削除し、user_auth
テーブルから物理削除する。
###実装
データモデルを実装していきます。今回はpostgresqlをsequelizeから使います。
まずはmodels
フォルダを作成し、その下に_sequelize-loader.js
と各データモデルuserTmp.js
、user.js
、userAuth.js
を作成します。それぞれ以下のように実装します。
'use strict';
const Sequelize = require('sequelize');
const sequelize = new Sequelize(
'postgres://postgres:postgres@localhost/sample_app',
{logging: true, operatorsAliases: false, timezone: 'UTC'}
);
module.exports = {
database: sequelize,
Sequelize: Sequelize
};
'use strict';
const loader = require('./_sequelize-loader');
const Sequelize = loader.Sequelize;
const UserTmp = loader.database.define('user_tmp', {
email: {
type: Sequelize.STRING,
primaryKey: true
},
username: {
type: Sequelize.STRING,
allowNull: false
},
password: {
type: Sequelize.STRING,
allowNull: false
},
token: {
type: Sequelize.STRING,
allowNull: false,
unique: true
}
}, {
freezeTableName: true,
timestamps: false
});
module.exports = UserTmp;
'use strict';
const loader = require('./_sequelize-loader');
const Sequelize = loader.Sequelize;
const User = loader.database.define('users', {
user_id: {
type: Sequelize.STRING,
primaryKey: true
},
username: {
type: Sequelize.STRING,
allowNull: false
},
created_at:Sequelize.DATE,
updated_at:Sequelize.DATE,
deleted_at:Sequelize.DATE
}, {
freezeTableName: true,
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
deletedAt: 'deleted_at',
paranoid: true
});
module.exports = User;
'use strict';
const loader = require('./_sequelize-loader');
const Sequelize = loader.Sequelize;
const UserAuth = loader.database.define('user_auth', {
username: {
type: Sequelize.STRING,
primaryKey: true
},
user_id: {
type: Sequelize.STRING,
allowNull: false
},
password: {
type: Sequelize.STRING,
allowNull: false
},
email: {
type: Sequelize.STRING,
allowNull: false
},
created_at:Sequelize.DATE,
updated_at:Sequelize.DATE
}, {
freezeTableName: true,
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at'
});
module.exports = UserAuth;
次にリレーションの設定をします。
app.js
に以下を追記します。
// モデルの読み込み
const User = require('./models/user');
const UserAuth = require('./models/userAuth');
const UserTmp = require('./models/userTmp');
// モデルのリレーション設定
User.sync().then(() => {
UserAuth.belongsTo(User, { foreignKey: 'user_id' });
UserAuth.sync();
UserTmp.sync();
});
以上でデータモデルの作成は完了です。
###DBを作成
postgresqlのDBを作成します。
$ sudo su - postgres
$ psql
# CREATE DATABASE sample_app;
#登録フォームの作成
次に、ユーザーに初期情報を入力してもらいサーバーに送信するフォームを作成します。
views/signup.pug
を作成し以下のように編集します。
html
head
meta(charset="utf-8")
meta(http-equiv="X-UA-Compatible" content="IE=edge")
meta(name="viewport" content="width=device-width, initial-scale=1")
title= title
body
if title == 'サインアップ'
form(action="/signup", method="post")
div
label
p ユーザー名:
input(type="text", name="username")
div
label
p メールアドレス:
input(type="email", name="email")
div
label
p パスワード:
input(type="password", name="password")
div
button(type="submit") サインアップ
p または
a(href="/login") ログイン
else
p アカウント確認メールを送信しました。メール内のリンクから認証を行っってください。
特殊な部分はありません。ユーザー名・メールアドレス・パスワードを/signup
にPOSTするだけです。
#サインアップWebAPIの実装
ここから実際の処理を書いていきます。
###フォームをレンダリングする
フォームは/signup
にGETアクセスしたときに表示するようにします。まずは、このパスへのGETをルーティングします。
app.js
に以下を追記します。
// ルーターの読み込み
const signupRouter = require('./routes/signup');
// ルート設定
app.use('/signup', signupRouter );
次に上記で読み込むルーターを作成します。
~/routes/signup.js
を作成し以下のように記述します。
'use strict';
const express = require('express');
const router = express.Router();
// '~/signup'にGETアクセスが来たときの処理
router.get('/', function(req, res, next){
res.render('signup');
});
module.exports = router;
早速起動しみます。
$ DEBUG=mail-auth:* yarn run start
$ DEBUG=mail-auth:* npm run start
Listening on port 3000
とログが表示され起動が確認できたら、chromeからhttp://localhost:3000/signupにアクセスしてみます。 1
このように表示されていればOKです。
###フォームから送信された情報をDBに保存する
次に、フォームからのPOSTアクセスを処理します。
signup.js
に以下を追記します。
// モジュールの読み込み
const crypto = require("crypto");
// モデルの読み込み
const UserTmp = require('../models/userTmp');
// '~/signup'にPOSTアクセスが来たときの処理
router.post('/', function(req, res, next){
const email = req.body.email;
const username = req.body.username;
const password = hashing(req.body.password);
const token = crypto.randomBytes(16).toString('hex');
const hashedToken = hashing(token);
UserTmp.upsert({
email: email,
username: username,
password: password,
token: hashedToken
}).then(() => {
// TODO 確認メールを送信する
});
});
});
// ハッシュ化関数
function hashing(data){
const shasum = crypto.createHash('sha1');
shasum.update(data);
let hash = shasum.digest('hex');
return hash;
}
この処理では、DB保存する情報を取り出して、新たにトークンを生成しています。DBに保存するのはハッシュ化したトークンで、もとのトークンの方をメールで送信する予定です。UserTmp.upsert()
で同じメールアドレスの未認証ユーザーが居たら上書きして、居なければ新たに行を追加しています。
###確認メールを送信する
DBにユーザー情報を保存できたらアカウント確認用のメールを送信します。
メール送信にはnodemailerと言うライブラリを利用します。
このライブラリはSMTPサーバーやGmail、Yahooメールと言ったサービスからの送信をサポートしています。
今回は簡単に使えるGmailで実装します。2
signup.js
に以下を追加します。
// モジュールの読み込み
const nodemailer = require('nodemailer');
// メール送信設定
const transporter = nodemailer.createTransport({
service: 'Gmail',
auth: {
user: 'yourmail@gmail.com', // Gmailアドレス
pass: 'hogehuga' // Googleアカウントのパスワード
}
});
const mailOptions = {
from: 'yourmail info <yourmail@gmail.com>',
subject: 'アカウントの確認'
};
実際に送信する処理は以下の通りです。TODO部分に追記します。
mailOptions.to = email;
mailOptions.html = '<p>以下のリンクからアカウントの確認を行ってください。</p><br>
<a href="localhost:3000/auth/email/' + token + '">アカウントを確認</a>';
transporter.sendMail(mailOptions, (err, info) => {
if (err){
console.log(err);
} else {
console.log('Message sent: ' + info.accepted);
res.render('signup', { title: 'アカウントの確認' });
}
}
早速サインアップしてメールが送信できるか確認してみましょう。すでに起動している場合はCtrl + C
でサーバーを止めてから以下のコマンドで再度起動させます。
$ DEBUG=mail-auth:* yarn run start
$ DEBUG=mail-auth:* npm run start
http://localhost:3000/signupにアクセスして、先程送信用に設定したメールアドレス以外で受け取れるメールアドレスでサインアップしてください。
サインアップボタンを押すと、このような表示になると思います。
ここで、DBの内容を確認してみます。
sample_app=# SELECT * FROM user_tmp ;
email | username | password | token
------------------------+------------+------------------------------------------+------------------------------------------
xxxxxxxxxx@yahoo.co.jp | show0317sw | 9d4e1e23bd5b727046a9e3bab7db57bd8d6ee684 | 16c65b4ad9a33c089a19dc3e017a7443b9650940
(1 row)
email
やusername
に間違いがなく、password
とtoken
がハッシュ化されていれば問題ありません。
###認証する
終盤に近づいてきました!
先程のサインアップで認証用のURLを含んだメールを登録したメールアドレス宛に送信されています。メールを確認してみます。
このようなメールが送信されていると思います。
URLから認証するために、リンクのパス/auth/email/:token
へのGETアクセスをルーティングします。
app.js
に以下を追記します。
// ルーターの読み込み
const authRouter = require('./routes/auth');
// ルート設定
app.use('/auth', authRouter);
次に上記で読み込むルーターを作成します。
~/routes/auth.js
を作成し以下のように記述します。
'use strict';
const express = require('express');
const router = express.Router();
// パッケージの読み込み
const crypto = require('crypto');
const uuid = require('uuid');
// モデルの読み込み
const UserTmp = require('../models/userTmp');
const User = require('../models/user');
const UserAuth = require('../models/userAuth');
// '~/auth/email/:token'にGETアクセスが来たときの処理
router.get('/email/:token', function(req, res, next){
const token = hashing(req.params.token);
UserTmp.findOne({
where: {
token: token
}
}).then(user => {
if (user){
const userId = uuid.v4();
const username = user.username;
const password = user.password;
const email = user.email;
User.create({
user_id: userId,
username: username
}).then(()=>{
UserAuth.create({
username:username,
user_id: userId,
password: password,
email: email
}).then(()=>{
UserTmp.destroy({
where:{
email: email
}
}).then(()=>{
res.render('auth', { title: '認証成功' });
});
});
});
} else {
res.render('auth', { title: '認証失敗' });
}
});
});
// ハッシュ化関数
function hashing(data){
const shasum = crypto.createHash('sha1');
shasum.update(data);
let hash = shasum.digest('hex');
return hash;
}
module.exports = router;
auth.js
ではURLに含まれるトークンをハッシュ化してuser_tmp
テーブルから検索しています。見つかったら認証成功、見つからなかったら認証失敗となります。
認証に成功した場合、user_tmp
に保存されていた情報をuser
テーブル、user_auth
テーブルにそれぞれ保存し直しています。その後、user_tmp
テーブルからは削除しています。
auth
をレンダリングするようにしていますが、ファイルはまだ作ってないのでこちらも新たに作成します。
~/views/auth.pug
を作成し、以下のように記述します。
html
head
meta(charset="utf-8")
meta(http-equiv="X-UA-Compatible" content="IE=edge")
meta(name="viewport" content="width=device-width, initial-scale=1")
title= title
body
if title == '認証成功'
p 認証に成功しました。
a(href="/login") ログイン
else
p 認証に失敗しました。もう一度、最初からやり直してください。
a(href="/signup") サインアップ
では、サーバーを再起動して、メール内のURLにアクセスしてみます。
認証に成功すると上記のように表示されると思います。
なおトークン部分を変えてアクセスしてみたり、同じトークンでアクセスしたりすると認証に失敗して、以下のような表示になります。
以上でメール認証でのサインアップは実装できました!!
サインアップすればログインがあるのは当たり前なのでログイン用のパスが見えたりしてましたが、今回の記事の目的とはちょっと違うので、別の記事で書くことにします。
#まとめ
- メール認証は、[トークン発行]→[メールでトークンを送信]→[トークンを照合] の3ステップ。
- ライブラリを使えば、意外と簡単に実装できます。便利な時代です。
- DB設計は良く考えよう。僕は何度も作り直してこの形に行き着きました。やりたいことに合わせた最強のDB設計をつくろう。
#おわりに
今回はメール認証を実際にコードを書きながら自分なりに解説してみました。
今回は実装していませんが、トークンに時間制限を設けたりするなど、安心安全なサービスの提供のためにできることはまだまだあると思います。こんなアイディアがあるぜ!!って方はぜひ教えてください。
個人的な感想になりますが、実務で実装したことが無く、独学でもそれなりに動いてくれて良かったです。やり方をいろいろと調べているうちに、ユーザー情報のDB設計のバリエーションや認証方法に様々な方法があったりと奥深さを感じ、それがとても楽しく感じました。
最後にこの場を借りて。N高アドカレにノリと勢いで参加しましたが執筆していく中で得たものの多さに驚いています。アドバイスを頂いたり、共にエラーを乗り越えたりと、多くの方に支えて頂きました。先生方やN高生のみなさんに本当にありがとうございます。卒業したくない()
そして、この記事が誰かの役に立てば幸いです。