Aurelia hack 06 ～ログインを実装してみる～

管理画面には必須のログイン・ログアウトをちょっとやってみましょう。

# サーバサイドも含めた live reload を構築

[前回](http://qiita.com/sukobuto/items/8911991dfe9c3a796e75),[前々回](http://qiita.com/sukobuto/items/3892635e0caea19a2402)あたりからクライアントサイドだけでなくサーバサイドの開発も行っています。

今回はまずサーバサイドの修正もクライアントサイドの修正も即座に live reload によりブラウザに反映されるようにしてみましょう。（興味がなければ読み飛ばしてOKです）

## 追加モジュールをインストール

サーバサイドのソースコード変更を検知して自動的に再起動してくれる [nodemon](https://github.com/remy/nodemon) というツールがあります。これを gulp で扱うための [gulp-nodemon](https://www.npmjs.com/package/gulp-nodemon) を追加インストールします。

```shell-session
[you@server aurelia-skeleton-navigation]$ npm i --save-dev gulp-nodemon
```

## 'serve' タスクの修正

`build/tasks/serve.js` を修正します。

```js:build/tasks/serve.js
var gulp = require('gulp');
var nodemon = require('gulp-nodemon');
var browserSync = require('browser-sync');

// this task utilizes the browsersync plugin
// to create a dev server instance
// at http://localhost:9000
gulp.task('browsersync', ['build'], function(done) {
  browserSync({
    online: false,
    open: false,
    port: 9000,
    proxy: 'http://localhost:3000'
  }, done);
});

gulp.task('serve', ['browsersync'], function(done) {
  nodemon({
    script: 'server.babel.js',
    ext: 'js html css',
    ignore: [
      'node_modules',
      'build',
      'dist',
      'src',
      'public',
      'test'
    ],
    stdout: false // 標準出力を自動バイパスしない
  }).on('readable', function() {

    // 標準出力を監視して再起動が完了したら reload()
    this.stdout.on('data', function(chunk) {
      if (/^listening http on port/.test(chunk)) {
        browserSync.reload({ stream: false })
      }
      process.stdout.write(chunk); // 出力
    });

    this.stderr.on('data', function(chunk) {
      process.stderr.write(chunk);
    });

    done();

  });
});
```

## watch タスクの実行


```shell-session
[you@server aurelia-skeleton-navigation]$ gulp watch
[22:59:08] Using gulpfile ~\PhpstormProjects\aurelia-skeleton-navigation\gulpfile.js
[22:59:08] Starting 'build'...
[22:59:08] Starting 'clean'...
[22:59:08] Finished 'clean' after 17 ms
[22:59:08] Starting 'build-system'...
[22:59:08] Starting 'build-html'...
[22:59:08] Starting 'build-css'...
[22:59:08] Finished 'build-css' after 21 ms
[22:59:09] Finished 'build-html' after 1 s
[22:59:09] Finished 'build-system' after 1.05 s
[22:59:09] Finished 'build' after 1.07 s
[22:59:09] Starting 'browsersync'...
[BS] Proxying: http://localhost:3000
[BS] Now you can access your site through the following addresses:
[BS] Local URL: http://localhost:9000
[22:59:11] Finished 'browsersync' after 1.64 s
[22:59:11] Starting 'serve'...
[22:59:11] [nodemon] 1.8.1
[22:59:11] [nodemon] to restart at any time, enter `rs`
[22:59:11] [nodemon] watching: *.*
[22:59:11] [nodemon] starting `node server.babel.js`
Sat, 12 Dec 2015 13:59:19 GMT express-session deprecated undefined resave option; provide resave option at server.js:33:41
Sat, 12 Dec 2015 13:59:19 GMT express-session deprecated undefined saveUninitialized option; provide saveUninitialized option at server.js:33:41
[BS] [info] Reloading Browsers...
listening http on port 3000
```

画面上には listening http on port 3000 と表示されますが、browserSync サービスへ接続するために http://localhost:9000 へアクセスします。以降はこの状態でソースコードをいじっていきます。

# ログインの実装

## 追加モジュールをインストール

セッション管理のための [express-session](https://www.npmjs.com/package/express-session) と、サーバサイドレンダリングのためのテンプレートエンジン [ECT](http://qiita.com/sukobuto/items/b0be22bfebd721854e0b) を追加でインストールします。

```shell-session
[you@server aurelia-skeleton-navigation]$ npm i --save express-session ect
```

## ログインページテンプレートを作成

あらたに views ディレクトリを作成し、そこにログインページのためのテンプレートを置きます。

全体のレイアウトテンプレート

```html:views/layout.ect
<!DOCTYPE html>
<html>
<head>
	<title>Aurelia Hack</title>
	<link rel="stylesheet" href="/jspm_packages/npm/font-awesome@4.4.0/css/font-awesome.min.css">
	<link rel="stylesheet" href="/jspm_packages/github/twbs/bootstrap@3.3.5/css/bootstrap.min.css">
</head>
<body>
<% content %>
</body>
</html>
```

ログインページのテンプレート

```html:views/login.ect
<% extend 'layout' %>

<div style="padding: 200px;">
	<form action="login" method="post">
		<p><%= @error %></p>
		<div class="form-group">
			<label>ユーザID</label>
			<input type="text" class="form-control" name="login" value="<%= @login %>">
		</div>
		<div class="form-group">
			<label>パスワード</label>
			<input type="password" class="form-control" name="password">
		</div>
		<input type="submit" class="btn btn-primary btn-lg btn-block" value="ログイン">
	</form>
</div>
```

## ログイン・ログアウトアクションを実装

```js:server.js
import express from "express"
import bodyParser from "body-parser"
import serveStatic from "serve-static"
import ECT from 'ect'
import session from 'express-session'

let app = express();
let port = 3000;

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true}));
app.use(serveStatic(__dirname));
app.engine('ect', ECT({ watch:true, root: __dirname + '/views', ext: '.ect'}).render);
app.set('view engine', 'ect');
app.use(session({
	secret: 'iweolaljcoilsjfkluih',
	resave: false,
	saveUninitialized: false
}));

// テスト用のアカウントデータ
// ほんとは DB に格納されてる想定
// パスワードはハッシュ化しないとだめよ♡
let accounts = {
	okarin: {
		firstName: '倫太郎',
		lastName: '岡部',
		password: 'password'
	},
	kurigohan: {
		firstName: '紅莉栖',
		lastName: '牧瀬',
		password: 'password'
	}
};

/**
 * ログイン画面とログイン処理のアクション
 */
app.get('/login', (req, res) => {
	res.render('login');
});
app.post('/login', (req, res) => {
	let login = req.body.login;
	let password = req.body.password;
	// 引き当て
	if (!accounts.hasOwnProperty(login)) {
		return res.render('login', {
			error: 'ユーザが見つかりません',
			login: login
		});
	}
	let account = accounts[login];
	if (account.password !== password) {
		return res.render('login', {
			error: 'パスワードが一致しません',
			login: login
		})
	}
	// 転送
	req.session.account = account;
	req.session.token = Math.random().toString(36).slice(2, 22);
	res.redirect('/');
});
app.get('/logout', (req, res) => {
	req.session.account = null;
	res.redirect('/login');
});

/**
 * API セッションに有効な認証がない場合はログインを要求する
 */
app.use('/api/*', (req, res, next) => {
	if (!req.session.account) {
		return res.status(401).send('login required');
	}
	next();
});

/**
 * Aurelia 初期化API
 * アカウントデータとAPIトークンの取得
 */
app.get('/api/init', (req, res) => {
	return res.json({
		account: req.session.account,
		token: req.session.token
	});
});

app.listen(port, () => {
	console.log("listening http on port " + port)
});
```

## Aurelia でアカウントを扱う

ログインしていなければログインページに飛ぶようにしましょう。
またログイン済みの場合はアカウントデータを取得します。

### ログイン画面への強制遷移とアカウントデータサービス

[前回](http://qiita.com/sukobuto/items/8911991dfe9c3a796e75) 作成した ApiClient サービスに、レスポンスが 401 だった時に強制的にログイン画面へ遷移するロジックを加えます。
またアカウントデータ DI に登録するための Account クラスも用意します。

```js:src/services.js
import superagent from 'superagent'
import moment from 'moment'

export class Account {

	setData(data) {
		Object.assign(this, data);
	}

}

export class ApiClient {

	token;      // セキュリティトークン
	cache = {}; // キャッシュデータ格納オブジェクト

	/**
	 * キャッシュを保存する
	 * @param {string} key
	 * @param {any} content
	 */
	saveCache(key, content) {
		this.cache[key] = {
			content,
			since: moment().unix()
		};
		return this;
	}

	/**
	 * キャッシュを取得する
	 * @param {string} key
	 * @param {number} ttl
	 */
	getCache(key, ttl) {
		if (!this.cache.hasOwnProperty(key)) return null;
		let item = this.cache[key];
		if (item.since < moment().unix() - ttl) {
			this.purgeCache(key);
			return null;
		}
		return item.content;
	}

	/**
	 * キャッシュを削除する
	 * @param {string} key
	 */
	purgeCache(key) {
		if (this.cache.hasOwnProperty(key)) {
			delete this.cache[key];
		}
	}

	/**
	 * プレフィクスを指定してキャッシュを削除する
	 * @param {string} prefix
	 */
	purgeCaches(prefix) {
		for (var key in this.cache) {
			if (this.cache.hasOwnProperty(key) && key.lastIndexOf(prefix, 0) === 0) {
				delete this.cache[key];
			}
		}
	}

	/**
	 * GET リクエスト
	 * @param {string} url リクエストURL
	 * @param {any} [params] リクエストパラメータ
	 * @param {string} [cacheKey] キャッシュ保存キー
	 * @param {number} [cacheTtl] キャッシュ有効秒数
	 */
	async get(url, params, cacheKey, cacheTtl = 60) {
		// キャッシュチェック
		if (cacheKey) {
			let content = this.getCache(cacheKey, cacheTtl);
			if (content) return content;
		}

		// セキュリティトークンがある場合は付与
		params = params || {};
		if (this.token) params._token = this.token;
		let res = await new Promise((fulfilled, rejected) => {
			// GET リクエスト送信
			superagent.get(url)
				.query(params)
				.end((err, res) => {
					if (err) {
						// 401 なら強制的に遷移
						if (err.status == 401) {
							location.href = '/login';
						}
						rejected(err);
					}
					fulfilled(res.body);
				});
		});

		// キャッシュ保存
		if (cacheKey) {
			this.saveCache(cacheKey, res);
		}
		return res;
	}

	/**
	 * POST request
	 * @param {string} url リクエストURL
	 * @param {Object} [params] リクエストパラメータ(フォームデータ)
	 * @param {NodeList|Node} [files] input[type="file"] またはその NodeList
	 */
	async post(url, params, files) {
		params = params || {};
		if (this.token) params._token = this.token;
		let file_input;
		let req = superagent.post(url);
		for (var prop in params) {
			if (params.hasOwnProperty(prop)) {
				req.field(prop, params[prop]);
			}
		}
		// ファイルがある場合は添付する
		if (files) {
			if (files.hasOwnProperty(length)) { // when the NodeList
				for (var i = 0; i < files.length; i++) {
					file_input = files[i];
					req.attach(file_input.name, file_input.files[0], file_input.value);
				}
			} else {    // when an Element
				file_input = files;
				req.attach(file_input.name, file_input.files[0], file_input.value);
			}
		}
		let res = await new Promise((fulfilled, rejected) => {
			req.end((err, res) => {
				if (err) {
					// 401 なら強制的に遷移
					if (err.status == 401) {
						location.href = '/login';
					}
					rejected(err);
				}
				fulfilled(res.body);
			});
		});
		return res;
	}

}
```

### 初期化APIを使う (main.js)

Aurelia 起動前にまず `api/init` を呼び、ログイン情報を取得します。
もしログインしていなければ、ApiClient によって強制的にログイン画面へと遷移します。

```js:src/main.js
import 'bootstrap';
import {ApiClient, Account} from 'services'

export async function configure(aurelia) {
	aurelia.use
		.standardConfiguration()
		.developmentLogging();

	let client = aurelia.container.get(ApiClient);
	let res;
	// 初期化 API を呼び、ログインユーザ情報やトークンなどを取得する
	res = await client.get('api/init');
	// 応答コードが 401 なら起動せずログイン画面へ遷移する
	let account = aurelia.container.get(Account);
	account.setData(res.account);
	client.token = res.token;
	// 初期化後にアプリケーションを起動
	aurelia.start().then(a => a.setRoot());
}
```

余談:
ApiClient で強制的に遷移するのはやや強引で、ここ main.js で try/catch して 401 エラーなら遷移すればよいことではあります。ApiClient で遷移するのは、あらゆるタイミングで API を呼び出した時にセッションが切れていたとき、毎回ハンドリングするよりも勝手に遷移してくれたほうがありがたいからです。このへんは作るものの設計次第ですね。

### ログインできてるのかわかるように少し改造しましょう

welcome ページを少し改造して、ログインできているのかを確認しましょう。

View にログアウトボタンを追加します。

```html:src/welcome.html
<template>
  <section class="au-animate">
    <h2>${heading}</h2>
    <form role="form" submit.delegate="submit()">
      <div class="form-group">
        <label for="fn">First Name</label>
        <input type="text" value.bind="firstName & debounce:500" class="form-control" id="fn" placeholder="first name">
      </div>
      <div class="form-group">
        <label for="ln">Last Name</label>
        <input type="text" value.bind="lastName & debounce:700" class="form-control" id="ln" placeholder="last name">
      </div>
      <div class="form-group">
        <label>Full Name</label>
        <p class="help-block">${fullName | upper}</p>
      </div>
      <button type="submit" class="btn btn-default">Submit</button>
    </form>
    <br>
    <a href="/logout" class="btn btn-default">ログアウトする</a>
  </section>
</template>
```

ViewModel ではアカウント情報にアクセスします

```js:src/welcome.js
import {inject} from 'aurelia-framework';    // 追加
import {Account} from 'services'             // 追加

@inject(Account)                             // 追加
export class Welcome {
	heading = 'Welcome to the Aurelia Navigation App!';
	firstName = 'John';
	lastName = 'Doe';
	previousValue = this.fullName;

	// コンストラクタを追加し、注入されたアカウント情報を取得する
	constructor(account) {
		this.firstName = account.firstName;
		this.lastName = account.lastName;
	}
	
	get fullName() {
		return `${this.firstName} ${this.lastName}`;
	}

	submit() {
		this.previousValue = this.fullName;
		alert(`Welcome, ${this.fullName}!`);
	}

	canDeactivate() {
		if (this.fullName !== this.previousValue) {
			return confirm('Are you sure you want to leave?');
		}
	}
}

export class UpperValueConverter {
	toView(value) {
		return value && value.toUpperCase();
	}
}
```

今回の実装は以上です。試してみましょう。

<img width="713" alt="login.png" src="https://qiita-image-store.s3.amazonaws.com/0/25145/70ceb8d1-0bb0-0d9f-7785-0f6f0732fde1.png">

`okarin` または `kurigohan` でログインできます。
パスワードは `password` です。

<img width="713" alt="login.png" src="https://qiita-image-store.s3.amazonaws.com/0/25145/671cabea-4c6e-1425-592b-95649c3c1d69.png">

ログインできました。今回はここまでです。
尚ソースコードは https://github.com/sukobuto/aurelia-advent-calendar-2015 にあります。
ここまでの結果としてタグ「hack06」を付けてあります。