Node.JSとsqlite3で簡易掲示板もどきにログイン機能を追加します。これまでは誰でも投稿文を変更したり削除することができました。これではよくありません。また投稿をするのであればユーザー登録したあとログインしてからでないとできないようにします。
どうすればできるでしょうか? SQLite3で投稿された文章、投稿時刻、最終更新時刻を管理していましたが、ここに投稿したユーザー名も保存するようにするのはどうでしょうか? これならログインしているユーザーと投稿をしたユーザーが同一かどうかをチェックできるようになります。
Contents
パスワードの管理
ユーザー登録したときのユーザー名とパスワードもSQLite3に新しいテーブルをつくって管理します。ただしパスワードのような外部に流出したら困るようなものは暗号化して保存します。「外部に流出しないように気をつける」、これで外部流出を防げるのであれば誰も苦労しません。外部に流出しない仕組み作り、万一流出してもダメージを最小にする仕組み作りが必要です。
パスワードを暗号化(ハッシュ値)して保存しておき、ログインしようとしたユーザーがパスワードを入力したときにこれも暗号化して暗号化された状態で保存されているものと比較します。同じであれば正規のユーザーと判断することができます。暗号化されたものを復号する必要はありません。
同じ文字列 ‘123’のハッシュ値を2回取得してみることにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// npm install bcrypt でbcryptをインストールしておく let pass = '123'; let saltRounds = 12; // ストレッチング回数 2の何乗回か let salt = bcrypt.genSaltSync(saltRounds); let hashedPassword = bcrypt.hashSync(pass, salt); console.log(salt); console.log(hashedPassword); console.log(''); salt = bcrypt.genSaltSync(saltRounds); hashedPassword = bcrypt.hashSync(pass, salt); console.log(salt); console.log(hashedPassword); console.log(''); |
結果はこうなります。
1 2 3 4 5 |
$2b$12$ut.QeT/SsoxUmBDIYZO1FO $2b$12$ut.QeT/SsoxUmBDIYZO1FO6k/9hr6/sUiVs/XIdpc0r1pSqtyYV3a $2b$12$cVCpRQ6zqYiSsyR01.oste $2b$12$cVCpRQ6zqYiSsyR01.ostep5YNx2rCRjSBbcdwNtLCWKcQwIL5CJ2 |
最初の部分が同じようになっていますが、これは仕様です。bcrypt.genSaltSync()は29文字のハッシュ値を生成します。最初の$の後はバージョン情報、2番目の$の後はストレッチング回数、3番目の$の以降がソルト値になります。30文字目から60文字目までが実際のハッシュ値です。同じパスワードのハッシュ値なのにソルトが異なるため、まったく違う文字列になっています。
簡易掲示板にログイン機能を追加
それでは簡易掲示板にログイン機能を追加します。
最初にnpmでinstallすべきもの、requireする必要があるものを最初にまとめて示します。暗号化パッケージとしてbcrypt、 Expressでセッションを使うために express-sessionが必要です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
// npm install すべきもの // npm install ejs // npm install express // npm install sqlite3 // npm install bcrypt // npm install express-session // requireする必要があるもの const sqlite3 = require('sqlite3'); const express = require('express'); const session = require('express-session'); const bcrypt = require('bcrypt'); const fs = require('fs'); const ejs = require('ejs'); const qs = require('querystring'); // サーバーを立てる const app = express(); const port = 65003; var server = app.listen(port, function() { console.log("listening at port %s", server.address().port); }); |
ユーザー情報を管理するためのテーブルをつくる
まずデータベースを作り直します。すでにテーブルが存在するときはなにもおきません。そこで前回作成したtest.dbは一旦削除してCreateTables関数を実行します。
前回の変更点としてテーブル table_postsには投稿者のユーザー名が追加されています。table_usersは新しくつくります。あとデータベースのパスだけでなくテーブル名も同じものを使うので定数として定義しました。
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 |
const db_path = './test.db'; const table_posts_name = 'table_posts'; const table_users_name = 'table_users'; CreateTables(); function CreateTables(){ const db = new sqlite3.Database(db_path); db.run( `create table if not exists ${table_posts_name}(` + `id integer primary key autoincrement,` + `name text,` + `body text,` + `createtime datetime,` + `updatetime datetime,` + `writer text` + `)` ); db.run( `create table if not exists ${table_users_name}(` + `id integer primary key autoincrement,` + `username text unique,` + `password text,` + `salt text` + `)` ); db.close(); } |
ユーザーはログインしているのか?
ログイン処理、ログインしているかどうかを確認するためにセッションを使います。そこで以下の処理が必要です。クッキーの有効期間は3600秒、すなわち1時間とします。
1 2 3 4 5 6 7 |
const sess = { secret: bcrypt.genSaltSync(10), cookie: { maxAge: 3600 * 1000 }, resave: false, saveUninitialized: false, } app.use(session(sess)); |
トップページにアクセスしたら
ユーザーがトップページにアクセスさたら投稿を修正したり削除できるリンクの表示/非表示をしなければなりません。
最初にindex.ejsの内容を示します。
ここでは更新または削除できるのは自分が投稿したものだけであること、投稿をするのであればログインが必要であることを示すとともに、ログインしていないときはログインページへのリンク、ログインしているときはログアウトするためのリンクを表示させています。
また投稿をしたのがログインしたユーザーである場合だけ、更新と削除のリンクが表示されるようにしています。
index.ejs
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 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> <title>テスト</title> <link rel="stylesheet" href="./style.css" media="all"> </head> <body> <div id = "container"> <p>更新または削除できるのは自分が投稿したものだけです。</p> <% if(writer == '') { %> <a href="./newpost" rel="nofollow">新規投稿(要ログイン)</a> <a href="./login" rel="nofollow">ログイン</a><br> <% } else { %> <a href="./newpost" rel="nofollow">新規投稿</a> <a href="./logout" rel="nofollow">ログアウト</a><br> <% } %> <% for (let i = 0; i < rows.length; i++) { %> <% let row = rows[i] %> <div> <p> <strong><%= row.name %></strong> <% if(row.writer == writer) { %> <a href="./update<%= row.id %>" rel="nofollow">更新</a> <a href="./delete<%= row.id %>" rel="nofollow">削除</a> <% } %> <br> <%- row.body.replace(/\n/g, '<br>') %> <%- '<br>' %> 投稿:<%- row.createtime %> 最終更新:<%- row.updatetime %> </p> </div> <% } %> </div> </body> </html> |
次にindex.jsの内容ですが、ログインしているのであればreq.session.usernameを調べればログインしているユーザー名がわかります。req.session.usernameでユーザー名が取得できるようにする処理はあとで示します。
index.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
const index_ejs = fs.readFileSync('./index.ejs','utf8'); app.get('/', (req, res) => GetResponseIndex(req, res)); app.get('/index.html', (req, res) => GetResponseIndex(req, res)); function GetResponseIndex(req, res){ let writer = ""; if(req.session.username != undefined) writer = req.session.username; const db = new sqlite3.Database(db_path); db.all(`select * from ${table_posts_name}`, (error, rows) => { let content = ejs.render(index_ejs, {rows: rows, writer:writer}); res.writeHead(200, {'Content-Type':'text/html'}); res.write(content); res.end(); }); db.close(); } |
投稿をするためのページにアクセスしたときの処理を示します。
new_update.ejsの内容は変わりません。index.jsではログインしていないのに投稿をしようとした場合はログインページにリダイレクトします。それ以外の部分は同じです。
index.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
const new_update_ejs = fs.readFileSync('./new_update.ejs','utf8'); app.get('/newpost', (req, res) => { if(req.session.username == undefined){ res.redirect('./login'); return; } let content = ejs.render(new_update_ejs, { new_update: '新規投稿' ,action: './newpost', oldname: '名無しさん', oldbody: ''}); res.writeHead(200, {'Content-Type':'text/html'}); res.write(content); res.end(); }); |
新規投稿に関する処理
投稿ボタンがクリックされたときの処理を示します。SQLiteに登録するのは前回とはちがって、投稿名、投稿文、投稿時刻、最終更新時刻に加え、投稿者のユーザー名も登録しています。
SQL文をつくるときに単に文字列をつなげてつくるのは危険です。SQL文のなかに’がある場合、掲示板への書き込み内容に’が入っているとSQL文の内容が意図していたものとは違うものになってしまいます。それでエラーが発生するだけであればまだいいのですが、テーブルのデータをすべて消去したりユーザーのパスワードを表示させることもできてしまいます。
‘は”でエスケープ処理できます。これならエラーにはなりません。SQLインジェクション攻撃の定番である;DELETE FROM users–のような書き込みや検索をされてもそのように表示されるだけでSQLインジェクション攻撃を無力化させることができます。
index.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 35 36 37 38 39 40 41 42 |
const err_ejs = fs.readFileSync('./err.ejs','utf8'); app.post('/newpost', (req, res) => { // ログインしていないのであればなにもさせない if(req.session.username == undefined){ res.redirect('./login'); return; } let body = ''; let is413 = false; req.on('data', function(data) { body += data; // 大きすぎるデータは拒否 var maxData = 5 * 1000; if(data.length > maxData) { res.writeHead(413); let content = ejs.render(err_ejs, { err: '送信データのサイズは5KB以内にしてください'}); res.write(content); res.end(); is413 = true; } }); req.on('end', () => { if(is413) return; let post_data = qs.parse(body); const db = new sqlite3.Database(db_path); // req.sessionからログインしているユーザーのユーザー名を取得してSQLiteに登録する // 処理がおわったらトップページにリダイレクト db.run( `insert into ${table_posts_name}(name, body, createtime, updatetime, writer) ` + `values('${post_data.name.replace(/'/g, "''").replace(/</g, "<").replace(/>/g, ">")}', '${post_data.body.replace(/'/g, "''").replace(/</g, "<").replace(/>/g, ">")}', datetime('now', '+9 hours'), datetime('now', '+9 hours'), '${req.session.username.replace(/'/g, "''")}')` ); db.close(); res.redirect('./'); }); }); |
更新に関する処理
トップページの[更新]がクリックされたときの処理を示します。ログインしていないと[更新]のリンクは表示されないのですが、urlを自分で編集されてしまえばアクセスは可能です。そこで/update:idにアクセスされたときの処理もしています。
想定される場合として、(1)投稿文を投稿したユーザーによるアクセス、(2)ログインしていないユーザーのアクセス、(3)ログインしているが投稿文を投稿したユーザーではないユーザーのアクセスが考えられます。(1)であれば従来どおりの処理でいいのですが、(2)と(3)の場合はトップページにリダイレクトさせます。
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 |
app.get('/update:id', (req, res) => { // ログインしていないユーザーのアクセスはトップページにリダイレクト if(req.session.username == undefined){ res.redirect('./'); return; } let params = req.params; let query = `select * from ${table_posts_name} where id = ${params.id.replace(/'/g, "''")}`; const db = new sqlite3.Database(db_path); // ログインしているが投稿文を投稿したユーザーではないユーザーのアクセスもトップページにリダイレクト db.get(query, (err, row) => { if(row.writer != req.session.username){ res.redirect('./'); return; } // 投稿文を投稿したユーザーによるアクセスの場合 res.writeHead(200, {'Content-Type':'text/html'}); if(row != null){ let content = ejs.render(new_update_ejs, { new_update: '更新', action: `./update${params.id}`, oldname: row.name, oldbody: row.body}); res.write(content); } else { let content = ejs.render(err_ejs, { err: '404 ページが見つからない'}); res.write(content); } res.end(); }); db.close(); }); |
/update:idへpostされたときの処理を示します。投稿文を投稿したユーザーによるものであればデータベースを更新してトップページにリダイレクトします。それ以外の場合はなにもしないでトップページにリダイレクトさせます。
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 |
app.post('/update:id', (req, res) => { if(req.session.username == undefined){ res.redirect('./'); return; } let params = req.params; let body = ''; let is413 = false; req.on('data', function(data) { body += data; // 大量のデータを送りつけられたときの対策 var maxData = 5 * 1000; if(data.length > maxData) { res.writeHead(413); let content = ejs.render(err_ejs, { err: '送信データのサイズは5KB以内にしてください'}); res.write(content); res.end(); is413 = true; } }); req.on('end', () => { if(is413) return; let post_data = qs.parse(body); let query = `select * from ${table_posts_name} where id = ${params.id.replace(/'/g, "''")}`; const db = new sqlite3.Database(db_path); db.get(query, (err, row) => { if(row != null && req.session.username == row.writer){ db.run(`update ${table_posts_name} set name = ? where id = ?`, post_data.name.replace(/</g, "<").replace(/>/g, ">"), params.id); db.run(`update ${table_posts_name} set body = ? where id = ?`, post_data.body.replace(/</g, "<").replace(/>/g, ">"), params.id); db.run(`update ${table_posts_name} set updatetime = datetime('now', '+9 hours') where id = ?`, params.id); } res.redirect('./'); }); db.close(); }); }); |
削除に関する処理
削除の処理を示します。投稿文を投稿したユーザーによるものであればデータベースから削除してトップページにリダイレクトします。それ以外の場合はなにもしないでトップページにリダイレクトさせます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
app.get('/delete:id', (req, res) => { if(req.session.username == undefined){ res.redirect('/'); return; } let params = req.params; const db = new sqlite3.Database(db_path); let query = `select * from ${table_posts_name} where id = ${params.id.replace(/'/g, "''")}`; db.get(query, (err, row) => { if(req.session.username == row.writer){ db.run(`delete from ${table_posts_name} where id = ${params.id.replace(/'/g, "''")}`); res.redirect('./'); } }); db.close(); }); |
ユーザー登録に関する処理
さてログインの処理ですが、そのまえにユーザー登録できないとログインのしようがありません。ユーザー登録に関する処理を示します。
ユーザー登録のページを表示させるときに必要なsignup.ejsとsignup_err.ejsを示します。ユーザー登録でエラーが発生する可能性があります。
考えられるケースとして、パスワードを2回入力させるようにしているのですが、1回目と2回目が違う、ユーザー名またはパスワードが入力されていない、ユーザー登録しようとしているユーザー名が別の人に登録されているが考えられます。
このような場合はユーザーにエラーが発生したことを知らせ、もう一度ユーザー登録するように要請します。このようなことを考えないといけないのでsignup.ejsとsignup_err.ejsの2つを作成しています。
signup.ejs
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 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>ユーザー登録</title> </head> <body> <div id = "container"> <form action="./signup" method="post"> <label>ユーザー名</label> <input id="user" name="user"> <br> <label>パスワード(1回目)</label> <input id="password" name="password"> <br> <label>パスワード(2回目)</label> <input id="password2" name="password2"> <input type="submit" value="登録"> </form> <p>同じパスワードを2回入力してください。<br> すでに存在するユーザー名では登録できません。<br> パスワードは暗号化された状態で保存されるため、再発行はできません。<br> 忘れないようにしてください。</p> </div> </body> </html> |
signup_err.ejs
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 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>ユーザー登録</title> </head> <body> <div id = "container"> <p>登録失敗:すでに存在するユーザー名で登録しようとしたか、<br> パスワードの入力ミスです。もう一度やり直してください。</p> <form action="./signup" method="post"> <label>ユーザー名</label> <input id="user" name="user"> <br> <label>パスワード(1回目)</label> <input id="password" name="password"> <br> <label>パスワード(2回目)</label> <input id="password2" name="password2"> <input type="submit" value="登録"> </form> <p>同じパスワードを2回入力してください。<br> すでに存在するユーザー名では登録できません。<br> パスワードは暗号化された状態で保存されるため、再発行はできません。<br> 忘れないようにしてください。</p> </div> </body> </html> |
ユーザー登録するページの場合、getの場合はsignup_ejsを使うかsignup_err_ejsを使うかであり、たいした違いはありません。
index.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
const signup_ejs = fs.readFileSync('./signup.ejs','utf8'); const signup_err_ejs = fs.readFileSync('./signup_err.ejs','utf8'); app.get('/signup', (req, res) => { res.writeHead(200, {'Content-Type':'text/html'}); let content = ejs.render(signup_ejs); res.write(content); res.end(); }); app.get('/signup-err', (req, res) => { res.writeHead(200, {'Content-Type':'text/html'}); let content = ejs.render(signup_err_ejs); res.write(content); res.end(); }); |
登録ボタンをクリックしたときの処理を示します。どちらも/signupにpostされます。
2回入力したパスワードが異なる、すでに登録されているユーザー名で登録しようとした、そもそもユーザー名またはパスワードを入力していない場合はエラーです。/signup-errにリダイレクトさせます。そうでない場合はユーザーとしてデータベースに登録し、ログインページにリダイレクトさせます。
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 |
app.post('/signup', (req, res) => { let body = ''; req.on('data', function(data) { body += data; }); req.on('end', () => { let post_data = qs.parse(body); // 2回入力したパスワードが異なる、 // すでに登録されているユーザー名で登録しようとした、 // そもそもユーザー名またはパスワードを入力していない // これらの場合は/signup-errにリダイレクトしてやり直しさせる let userName = post_data.user; let password = post_data.password; let password2 = post_data.password2; if(password == '' || userName == '' || password != password2){ res.redirect('./signup-err'); return; } let query = `select * from ${table_users_name} where username = '${userName.replace(/'/g, "''")}'`; const db = new sqlite3.Database(db_path); db.get(query, (err, user) => { if(user != null){ res.redirect('./signup-err'); return; } else { // 正しく入力されている場合は/loginにログインページにリダイレクトする let salt = bcrypt.genSaltSync(12); let hashedPassword = bcrypt.hashSync(password, salt); db.run(`insert into ${table_users_name} (username, password, salt) values('${userName.replace(/'/g, "''")}', '${hashedPassword.replace(/'/g, "''")}', '${salt.replace(/'/g, "''")}')`); res.redirect('./login'); } }); db.close(); }); }); |
ログインに関する処理
ログインの処理ですが、先にlogin.ejsとlogin_err.ejsを示します。ログインに失敗した場合は失敗したということがわかるように1回目のログインページとは別のページを表示させます。
またログインページにリダイレクトされた場合、そのユーザーはユーザー登録をしていない場合もあるので、ユーザー登録ページへのリンクも設置します。
login.ejs
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 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>ログイン</title> </head> <body> <div id = "container"> <form action="./login" method="post"> <label>ユーザー名</label> <input id="user" name="user"> <br> <label>パスワード</label> <input id="password" name="password"> <br> <input type="submit" value="ログイン"> </form> </div> <p>まだユーザー登録していない方はこちらからどうぞ</p> <p><a href="./signup">新規ユーザー登録</a></p> </body> </html> |
login_err.ejs
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 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>ログイン</title> </head> <body> <div id = "container"> <p>ログインできません。ユーザー名とパスワードを入力しなおしてください。</p> <form action="./login" method="post"> <label>ユーザー名</label> <input id="user" name="user"> <br> <label>パスワード</label> <input id="password" name="password"> <br> <input type="submit" value="ログイン"> </form> </div> <p>まだユーザー登録していない方はこちらからどうぞ</p> <p><a href="./signup">新規ユーザー登録</a></p> </body> </html> |
index.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
const login_ejs = fs.readFileSync('./login.ejs','utf8'); const login_err_ejs = fs.readFileSync('./login_err.ejs','utf8'); app.get('/login', (req, res) => { res.writeHead(200, {'Content-Type':'text/html'}); let content = ejs.render(login_ejs); res.write(content); res.end(); }); app.get('/login-err', (req, res) => { res.writeHead(200, {'Content-Type':'text/html'}); let content = ejs.render(login_err_ejs); res.write(content); res.end(); }); |
/loginにpostされたときの処理ですが、まずユーザー名がデータベースに登録されているか調べます。登録されている場合は入力されたパスワードのハッシュ値がデータベースに登録されているパスワードのハッシュ値と同じか調べます。同じであればログイン処理をおこないます。ログインしたらセッションにユーザー名を格納し、トップページにリダイレクトさせます。
ユーザー名がデータベースに登録されていない場合、ユーザー名がデータベースに登録されているが、パスワードが合わない場合はログインはできません。/login-errにリダイレクトさせます。
index.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 35 36 37 38 |
app.post('/login', (req, res) => { let body = ''; req.on('data', function(data) { body += data; }); req.on('end', () => { let post_data = qs.parse(body); let userName = post_data.user; let password = post_data.password; const db = new sqlite3.Database(db_path); let query = `select * from ${table_users_name} where username = '${userName}.replace(/'/g, "''")'`; // 入力されたユーザー名はデータベースに登録されているか? db.get(query, (err, user) => { if(user != null){ // パスワードのハッシュ値がデータベースに登録されているハッシュ値と同じか? let salt = user.salt; let hashedPassword = bcrypt.hashSync(password, salt); // 条件をみたしていたらログイン処理 // そうでないなら/login-errへリダイレクト if(user.password == hashedPassword){ req.session.regenerate((err) => { // セッションにユーザー名を格納 req.session.username = userName; res.redirect('./'); }); return; } } res.redirect('./login-err'); }); db.close(); }); }); |
ログアウトするときの処理
ログアウトするときの処理を示します。req.session.destroy関数を実行して、トップページにリダイレクトします。
1 2 3 4 5 |
app.get('/logout', (req, res) => { req.session.destroy((err) => { res.redirect('./'); }); }); |
存在しないページにアクセスされたときの処理
存在しないページにアクセスされたときの処理は前回と同じです。
1 2 3 4 5 6 |
app.use((req, res) => { let content = ejs.render(err_ejs, { err: '404 ページが見つからないです'}); res.writeHead(200, {'Content-Type':'text/html'}); res.write(content); res.end(); }); |