Node.jsでWebApp+認証サーバーを作る
日付 タグ node.js カテゴリ node.js目次
WebApp(Express)+認証サーバー(Restify)
今回の記事は今までの記事の焼き直し的な部分があるが、
Node.jsでRestify+Sequelizeでユーザー認証ここでRestifyで作った認証サーバーがあるのだが、この時はSuperagentを使ったHTTPクライアントからアクセスして動作チェックをしていた。
一方で、以前にExpressのフレームワークでPassportを使ってセッション情報を利用しつつユーザー認証する記事を書いている。
Node.jsでExpress上でPassportを利用するこれら2つを今回は合わせて、ExpressのフレームワークでPassportを使ってセッション情報を利用しつつ、Superagentを利用してRestifyで作ったユーザー認証サーバーにユーザー認証を依頼し、認証が通ったら、その結果をセッションに保存するような流れを作ってみたい。
つまり出来上がる処理としては、以下の流れのようになる。
それぞれで主に使っているパッケージ、モジュールは
- UserAuthServer
- restify
- sequelize
- sqlite3
- bcrypt
- joi
- WebApp
- express
- passport
- redis
ただ、これ以外にも細々と利用している他のものもある。
なお、今回作ったサンプルのソースコードのうちWebAppの方は参照用としてGithubの以下に置いてある。 https://github.com/hugodeblog/node-passport-Restify
Restifyで作ったユーザー認証サーバーの方は以前に説明した以下である。 https://github.com/hugodeblog/node-restify-sequelize
Redisのインストール
以前のサンプルコードではセッションはファイルに保存していたのだが、今回はRedisサーバーにセッションを保存してみたいので、手元のMacにまずはRedisをインストール。
$ brew install redis
インストールが終わったら、コマンドコンソールからRedisサーバーを起動しておく。
$ redis-server
別のコンソールからデフォルトの6379番ポートでRedisサーバーが待ち受けしているか確認。
$ lsof -i:6379
またRedisサーバーのキーバリューの保存状態を見るには、cliコマンドよりMedisというGUIツールを使った方が便利である。
https://github.com/luin/medis - Medis
Mac版 v0.5.0までは無料でバイナリ版もあったが、v0.6.0からはAppStoreでバイナリ版は販売になっているようだ。
Restifyによるユーザー認証サーバー
Restifyによるユーザー認証サーバー(UserAuthServer)は以前に使ったものをそのまま利用する。
(
Node.jsでRestify+Sequelizeでユーザー認証
)
https://github.com/hugodeblog/node-restify-sequelize
にソースコードがあるので、これを取ってきて、
$ npm install
で必要なパッケージ等を入れたら、
$ BASIC_AUTH_USER=test BASIC_AUTH_PASS=password npm run start-server
> node-restify-sequelize@1.0.0 start-server
> DEBUG=db:*,users:* node ./user-server.mjs
users:log Rest-API-Test listening at http://127.0.0.1:4000 +0ms
Restifyによるユーザー認証サーバーは4000番ポートで待ち受け状態となる。
WebApp(Express)の構築
今回作るWebAppは基本部分は
Node.jsでExpress上でPassportを利用する
をベースにしている。
まずは作業ディレクトリの作成と必要なパッケージモジュール等のインストールから。
$ mkdir node-passport-restify
$ cd node-passport-restify
$ npx express-generator@4.x --view=hbs
$ npm install
$ npm install express-session --save
$ npm install session-file-store --save
$ npm install passport --save
$ npm install passport-local --save
$ npm install superagent --save
$ npm install connect-redis --save
$ npm install redis --save
アプリ本体はapp.mjs とするので、package.json は
package.json{
"name": "node-passport",
"version": "0.0.0",
"private": true,
"scripts": {
"start": "node ./app.mjs"
},
"dependencies": {
"connect-redis": "^5.0.0",
"cookie-parser": "~1.4.4",
"debug": "~2.6.9",
"express": "~4.16.1",
"express-session": "^1.17.1",
"hbs": "~4.0.4",
"http-errors": "~1.6.3",
"morgan": "~1.9.1",
"passport": "^0.4.1",
"passport-local": "^1.0.0",
"redis": "^3.0.2",
"session-file-store": "^1.5.0",
"superagent": "^6.1.0"
}
}
さて、主に以下がアプリに関わる部分であるが、
.
├── ./app.mjs
├── ./approotdir.mjs
├── ./appsupport.mjs
├── ./routes
│ ├── ./routes/index.mjs
│ └── ./routes/users.mjs
└── ./views
├── ./views/error.hbs
├── ./views/index.hbs
├── ./views/layout.hbs
├── ./views/login-failure.hbs
├── ./views/login.hbs
├── ./views/signup-failure.hbs
├── ./views/signup-success.hbs
└── ./views/signup.hbs
approotdir.mjs 、appsupport.mjs は以前と同じである。
approotdir.mjsimport * as path from 'path';
import * as url from 'url';
const __filename = url.fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export const approotdir = __dirname;
import { server, port } from './app.mjs';
import * as util from 'util';
/**
* Normalize a port into a number, string, or false.
*/
export function normalizePort(val) {
const port = parseInt(val, 10);
if (isNaN(port)) {
// named pipe
return val;
}
if (port >= 0) {
// port number
return port;
}
return false;
}
/**
* Event listener for HTTP server "error" event.
*/
export function onError(error) {
if (error.syscall !== 'listen') {
throw error;
}
const bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(`${bind} requires elevated privileges`);
process.exit(1);
break;
case 'EADDRINUSE':
console.error(`${bind} is already in use`);
process.exit(1);
break;
default:
throw error;
}
}
/**
* Event listener for HTTP server "listening" event.
*/
export function onListening() {
const addr = server.address();
const bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr.port;
console.log(`Listening on ${bind}`);
}
export function handle404(req, res, next) {
const err = new Error('Not Found');
err.status = 404;
next(err);
}
export function basicErrorHandler(err, req, res, next) {
// http://expressjs.com/en/guide/error-handling.html
if (res.headersSent) {
return next(err)
}
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};
// render the error page
res.status(err.status || 500);
res.render('error');
}
async function catchProcessDeath() {
console.log('shutdown ...');
await server.close();
process.exit(0);
}
process.on('uncaughtException', function(err) {
console.error("Crashed - "+ (err.stack || err));
});
process.on('unhandledRejection', (reason, p) => {
console.error(`Unhandled Rejection at: ${util.inspect(p)} reason: ${reason}`);
});
process.on('SIGTERM', catchProcessDeath);
process.on('SIGINT', catchProcessDeath);
process.on('SIGHUP', catchProcessDeath);
process.on('exit', () => { console.log('exiting...'); })
アプリ本体となるapp.mjs であるが、
app.mjsimport { default as express } from 'express';
import { default as hbs } from'hbs';
import * as path from'path';
import { default as logger } from'morgan';
import { default as cookieParser } from'cookie-parser';
import { default as bodyParser } from'body-parser';
import * as http from 'http';
import { approotdir } from './approotdir.mjs';
const __dirname = approotdir;
import {
normalizePort, onError, onListening, handle404, basicErrorHandler
} from './appsupport.mjs';
import { router as indexRouter } from './routes/index.mjs';
import { router as usersRouter } from './routes/users.mjs';
export const app = express();
// Viewセットアップ
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'hbs');
hbs.registerPartials(path.join(__dirname, 'partials'));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
// セッション設定
import session from 'express-session';
import sessionFileStore from 'session-file-store';
import { initPassport } from './routes/users.mjs';
const FileStore = sessionFileStore(session);
export const sessionCookieName = 'mycookie.sid';
const sessionSecret = 'keyboard hogehoge';
//const sessionStore = new FileStore({ path: "sessions" });
import ConnectRedis from 'connect-redis';
import redis from 'redis';
const RedisStore = ConnectRedis(session);
const redisClient = redis.createClient({
host: 'localhost',
port: 6379
})
redisClient.on('error', function (err) {
console.log('Could not establish a connection with redis. ' + err);
});
redisClient.on('connect', function (err) {
console.log('Connected to redis successfully');
});
const sessionStore = new RedisStore({ client: redisClient });
app.use(session({
store: sessionStore,
secret: sessionSecret,
resave: false,
saveUninitialized: false,
name: sessionCookieName,
cookie: {maxAge: 60*60*1000*24*7, secure: false}
}));
initPassport(app);
// ルーティング
app.use('/', indexRouter);
app.use('/users', usersRouter);
app.use(handle404);
app.use(basicErrorHandler);
export const port = normalizePort(process.env.PORT || '3000');
app.set('port', port);
export const server = http.createServer(app);
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
以前の記事をPassportを扱った時とほぼ同じなのだが、セッションを保存する場所をファイルからRedisサーバーにしているので、
//const sessionStore = new FileStore({ path: "sessions" }); import ConnectRedis from 'connect-redis'; import redis from 'redis'; const RedisStore = ConnectRedis(session); const redisClient = redis.createClient({ host: 'localhost', port: 6379 }) redisClient.on('error', function (err) { console.log('Could not establish a connection with redis. ' + err); }); redisClient.on('connect', function (err) { console.log('Connected to redis successfully'); }); const sessionStore = new RedisStore({ client: redisClient });
上記の部分に変更が加わっている。Redisサーバーはローカル上で6379番ポートで待ち受けを想定している。
WebApp(Express)のルーティング処理
ルーティング処理としては以下の2つがある。
├── ./routes
│ ├── ./routes/index.mjs(トップページ)
│ └── ./routes/users.mjs(ログインフォーム、サインアップフォーム)
import * as util from 'util';
import { default as express } from 'express';
export const router = express.Router();
// ホームページ
router.get('/', async (req, res, next) => {
try {
// 全メモリストを取得して表示
res.render('index', {title: 'Passport Test', user: req.user ? req.user : undefined});
} catch (err) {error(err); next(err);}
});
これは以前のPassportのサンプルの時のまま。
users.mjsimport path from 'path';
import util from 'util';
import { default as express } from 'express';
import { default as passport } from 'passport';
import { default as passportLocal } from 'passport-local';
const LocalStrategy = passportLocal.Strategy;
import { sessionCookieName } from '../app.mjs';
import {default as request} from 'superagent';
import * as url from 'url';
const URL = url.URL;
const BasicAuthKey = {
user: process.env.BASIC_AUTH_USER,
pass: process.env.BASIC_AUTH_PASS
};
function requestURL(path) {
const requrl = new URL('http://localhost:4000');
requrl.pathname = path;
return requrl.toString();
}
export const router = express.Router();
export function initPassport(app) {
app.use(passport.initialize());
app.use(passport.session());
}
// ログイン用ストラテジー
passport.use(new LocalStrategy({
passReqToCallback : false
},
async (username, password, done) => {
console.log('passport strategy called');
console.log(`username = ${username}, passowrd = ${password}`);
try {
var res = await request.post(requestURL('/password-check'))
.timeout({response: 5*1000, deadline: 10*1000})
.send({'username':username, 'password':password})
.set('Content-Type', 'application/json')
.auth(BasicAuthKey.user, BasicAuthKey.pass);
console.log({ id: res.body.id, username: res.body.username});
done(null, { id: res.body.id, username: res.body.username});
} catch(err) {
if(err.response && err.response.status && err.response.body) {
//done(null, false, `stauts=>${err.response.status}, message=>${err.response.body}`);
done(null, false);
}
else {
done(err);
}
}
}
));
passport.serializeUser(function(user, done) {
console.log('serializeUser called');
try {
console.log(`serialized user=${user}`);
done(null, user);
} catch (e) { done(e); }
});
passport.deserializeUser(async (user, done) => {
console.log('deserializeUser called');
try {
console.log(`deserialized user=${user}`);
done(null, user);
} catch(e) { done(e); }
});
// 認証ログイン画面
router.get('/login', function(req, res, next) {
console.log('/login called');
try {
res.render('login', {title: 'ログイン', user: req.user ? req.user : undefined});
} catch (e) { next(e); }
});
// ログインを試す
router.post('/login',
passport.authenticate('local', {
successRedirect: '/', // 成功時のログイン先
failureRedirect: 'login-failure', // 失敗時
})
);
// 認証キャッシュを削除する
router.get('/logout', function(req, res, next) {
try {
req.session.destroy();
req.logout();
res.clearCookie(sessionCookieName);
res.redirect('login');
} catch (e) { next(e); }
});
// ログイン失敗
router.get('/login-failure', function(req, res, next) {
try {
res.render('login-failure');
} catch (e) { next(e); }
});
// サインアップページ
router.get('/signup', function(req, res, next) {
console.log('get /signup called');
try {
res.render('signup', {title: 'サインアップ', user: req.user ? req.user : undefined});
} catch (e) { next(e); }
});
// サインアップ処理
router.post('/signup', async function(req, res, next) {
try {
var result = await request.post(requestURL('/users'))
.timeout({response: 5*1000, deadline: 10*1000})
.send({'username':req.body.username, 'password':req.body.password})
.set('Content-Type', 'application/json')
.auth(BasicAuthKey.user, BasicAuthKey.pass);
console.log({ id: result.body.id, username: result.body.username});
res.redirect('signup-success');
} catch (err) {
if(err.response && err.response.status && err.response.body)
res.redirect('signup-failure?error=' + `stauts=>${err.response.status}, message=>${err.response.body}`);
else
res.redirect('signup-failure?error=' + err);
}
});
// サインアップ失敗
router.get('/signup-failure', function(req, res, next) {
var passedVariable = req.query.error;
try {
res.render('signup-failure', {message: passedVariable ? passedVariable : undefined});
} catch (e) { next(e); }
});
// サインアップ成功
router.get('/signup-success', function(req, res, next) {
try {
res.render('signup-success');
} catch (e) { next(e); }
});
これもほとんどPassportのテストを行った時のものに近いのだが、その時はユーザー情報はメモリ上の配列に保存されているものでテストしていた。
しかし、今回はユーザー情報はユーザー認証サーバーにあり、そこへのAPIによる問い合わせが必要である。 そのため、Passportの認証ストラテジーが違ってくる。
// ログイン用ストラテジー passport.use(new LocalStrategy({ passReqToCallback : false }, async (username, password, done) => { console.log('passport strategy called'); console.log(`username = ${username}, passowrd = ${password}`); try { var res = await request.post(requestURL('/password-check')) .timeout({response: 5*1000, deadline: 10*1000}) .send({'username':username, 'password':password}) .set('Content-Type', 'application/json') .auth(BasicAuthKey.user, BasicAuthKey.pass); console.log({ id: res.body.id, username: res.body.username}); done(null, { id: res.body.id, username: res.body.username}); } catch(err) { if(err.response && err.response.status && err.response.body) { //done(null, false, `stauts=>${err.response.status}, message=>${err.response.body}`); done(null, false); } else { done(err); } } } ));
ユーザー認証のためのID/Passwordの組み合わせチェックはユーザー認証サーバー側で
http://localhost:4000/password-check
で提供されているので、そこにID/Passwordの組み合わせを送付して、ステータス200で結果が返ってくるかどうかで認証が通ったかどうかわかる。
例えば、curlを使ってサンプルとして返り値を見てみると、
$ curl -i -X POST -H "Content-Type: application/json" --user "test:password" -d '{"username":"youyouyou", "password":"123456789"}' http://localhost:4000/password-check
HTTP/1.1 200 OK
Server: Rest-API-Test
Content-Type: application/json
Content-Length: 79
Date: Sat, 09 Jan 2021 14:51:48 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"id":2,"username":"youyouyou","address":"Hokkaido","message":"auth check: ok"}
ID、名前、アドレスが返ってきて、メッセージとして"auth check: ok"という文字列である。 Passportのログイン用ストラテジーでは、返ってきた値を、
done(null, { id: res.body.id, username: res.body.username});
として保存している。そのため、IDと名前はreq.user.id, req.user.usernameとして以降参照できるようになる。 ログイン成功してトップページへリダイレクトされた時には、ビューにreq.userを渡せば、ビューでこれらの値を扱うことができる。
// ログインを試す router.post('/login', passport.authenticate('local', { successRedirect: '/', // 成功時のログイン先 failureRedirect: 'login-failure', // 失敗時 }) );
ログイン認証成功の場合はトップページにリダイレクト、ログイン認証失敗の場合は/users/login-failureにリダイレクトすることにしている。
サインアップの方はPassportの認証は使っておらず、まずユーザー認証サーバーに新規ユーザー登録をリクエストして成功したかどうかで、 成功した場合は、またログイン処理を通してPassportで認証するようにしている。
// サインアップ処理 router.post('/signup', async function(req, res, next) { try { var result = await request.post(requestURL('/users')) .timeout({response: 5*1000, deadline: 10*1000}) .send({'username':req.body.username, 'password':req.body.password}) .set('Content-Type', 'application/json') .auth(BasicAuthKey.user, BasicAuthKey.pass); console.log({ id: result.body.id, username: result.body.username}); res.redirect('signup-success'); } catch (err) { if(err.response && err.response.status && err.response.body) res.redirect('signup-failure?error=' + `stauts=>${err.response.status}, message=>${err.response.body}`); else res.redirect('signup-failure?error=' + err); } });
そのため、成功の場合は一旦/users/signup-successページにリダイレクトし、再度ログインページへ行くような形にしている。
一方、サインアップが何らかの場合に失敗した時は/users/signup-failureへerrorメッセージ付きでリダイレクトしている。
WebApp(Express)のビュー処理
└── ./views
├── ./views/error.hbs
├── ./views/index.hbs
├── ./views/layout.hbs
├── ./views/login-failure.hbs
├── ./views/login.hbs
├── ./views/signup-failure.hbs
├── ./views/signup-success.hbs
└── ./views/signup.hbs
layout.hbs 、error.hbs については特に特筆すべき点はない。express-generatorが用意してくれたものをそのまま用いれば良い。
layout.hbs
<!DOCTYPE html>
<html>
<head>
<title>{{title}}</title>
<link rel='stylesheet' href='/stylesheets/style.css' />
</head>
<body>
{{{body}}}
</body>
</html>
error.hbs
<h1>{{message}}</h1>
<h2>{{error.status}}</h2>
<pre>{{error.stack}}</pre>
トップページのindex.hbs だが、Passportでログイン認証がされていると、先ほどの説明で出てきたreq.userにID/名前が入っている状態になる。
index.hbs
<h1>{{title}}</h1>
<p>トップページです</p>
{{#if user}}
<h2>ログインユーザー</h2>
<p>ユーザー認証されています</p>
<ul>
<li>ユーザーID:{{user.id}}</li>
<li>ユーザー名:{{user.username}}</li>
</ul>
<a href="/users/logout">ログアウトする</a>
<hr>
<h2>登録ユーザーのみのコンテンツ</h2>
<p>これが見れるのは登録ユーザーのみです</p>
{{else}}
<p>ユーザー認証されていません</p>
<p>コンテンツを閲覧にするにはログインするか、ユーザー登録してください</p>
<a href="/users/login">ログイン</a><br>
<a href="/users/signup">新規登録</a>
{{/if}}
ログインがまだできていない状態では、ログインページ、あるいは新規登録ページへのリンクを表示して、ログインができている状態だと、ユーザーIDとユーザー名を表示して、ユーザーのみが見れるようなコンテンツを表示することを仮定した作りになっている。
login.hbs
<h1>{{title}}</h1>
<p>ログインするページです</p>
{{#if user}}
<p>すでにログイン済みです</p>
<ul>
<li>ユーザーID:{{user.id}}</li>
<li>ユーザー名:{{user.username}}</li>
</ul>
<a href="/users/logout">ログアウトする</a>
{{else}}
<form action="/users/login" method="post">
<div>
<label>ユーザーID:</label>
<input type="text" name="username" required="required"/>
</div>
<div>
<label>パスワード:</label>
<input type="password" name="password" required="required"/>
</div>
<div>
<input type="submit" value="ログイン"/>
</div>
</form>
{{/if}}
<br>
<a href="/">トップページに戻る</a><br>
トップページ同様、Passportによるログイン認証がされていると、req.userとしてuser情報が渡ってくるので、値が入っているかどうかでログイン済みかどうかを見ている。
未ログインの場合はログインフォームを表示し、ログイン済みの場合はログイン済みであることを表示してトップページへのリンクを表示。
なお、Passportでログイン認証失敗に表示されるのは
login-failure.hbs
<h1>ログインに失敗しました</h1>
<a href="/">トップページに戻る</a><br>
<a href="/users/login">ログインする</a>
ユーザー登録のサインアップページは以下。
signup.hbs
<h1>{{ title }}</h1>
<p>ユーザー登録をするページです</p>
{{#if user}}
<p>すでにログイン済みです</p>
<ul>
<li>ユーザーID:{{user.id}}</li>
<li>ユーザー名:{{user.username}}</li>
</ul>
<a href="/users/logout">ログアウトする</a>
{{else}}
<form action="/users/signup" method="post">
<div>
<label>ユーザーID:</label>
<input type="text" name="username" required="required"/>
</div>
<div>
<label>パスワード:</label>
<input type="password" name="password" required="required"/>
</div>
<div>
<label>アドレス :</label>
<input type="text" name="address" required="required"/>
</div>
<div>
<input type="submit" value="登録"/>
</div>
</form>
{{/if}}
<br>
<a href="/">トップページに戻る</a>
すでに存在するユーザー名と同じユーザーを作ろうとしたり、ユーザー名、パスワードのJoiによるバリデーションに失敗した時のリダイレクト先は、
signup-failure.hbs
<h1>サインアップに失敗しました</h1>
{{#if message}}
<p>{{message}}</p>
{{else}}
<p>{{定義されてません}}</p>
{{/if}}
<a href="/">トップページに戻る</a><br>
<a href="/users/signup">サインアップする</a>
ユーザー登録のサインアップ成功時のページは
signup-success.hbs
<h1>サインアップに成功しました</h1>
<p>サービスを続けるにはログインしてください</p>
<a href="/users/login">ログインする</a>
サインアップ成功時には一旦サインアップ成功したメッセージを表示して、再度ログイン処理を通して、Passport認証するようにしている。
アプリの動作検証
すでにRestifyで作った認証サーバーは上で述べた手順で起動済みであるが、ここでWebAppとなるユーザーに見える側の方のアプリを起動させる。
なお、本手順の前にRestifyによる認証サーバー、Redisサーバーが立ち上がっていることは確認しておいて欲しい。
$ BASIC_AUTH_USER=test BASIC_AUTH_PASS=password npm run start
> node-passport@0.0.0 start
> node ./app.mjs
Listening on port 3000
Connected to redis successfully
以下の流れで動作チェックをしてみたい。
- 登録済みユーザーmememeでログインしようとするがパスワード打ち間違えでログイン失敗
- 再びログインフォームに戻って今度はユーザーmememeで正しく入力してログイン
- トップページにログイン済みでリダイレクトされる
- mememeからログアウトする
- 今度はサインアップへ進み、既存ユーザーmememeと同じ名前でユーザーを作成しようとする
- 同じユーザー名でのユーザー作成は許可されていないので失敗する
- それでは違うユーザー名youyouyouというユーザー名でユーザー登録を試みる
- ユーザー登録が成功し、ユーザーログインのページへ移動してyouyouyouとしてログイン
- youyouyouでログイン成功してトップページ
この流れで動作チェックした結果
動作的には想定通りになったようだ。また、Redisサーバーにセッションを保存するようにしているが、Medisを使ってRedisの中身を見ると、 確かにセッションが保存されている。
以上、今回作ったサンプルアプリの説明であった。