AngularJS でログインのフローを作る

AngularJS で、よくあるログインのようなフローを作ってみました。

 - 未ログインならログインページを表示する
    - 他の任意のURLはログインページへリダイレクトする
 - ログイン済みでログインページを表示しようとしたときはトップページにリダイレクトする
 - ナビゲーションの表示をログイン状態に応じて変化させる

## index.html

最低限の見た目のために Bootstrap を使っていますが、Bootstrap の CSS で `!important` な `display:block` が指定されているところがあり、ngCloak や ngShow が効かなくなることがあったのでそれらを上書きするための CSS を html 文中に書いています。

```html:index.html
<!doctype html>
<html ng-app="App">
<head>
<meta charset="utf-8">
<title>AngularJS Example</title>
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.css"/>
<style>
#body [ng-cloak],
#body .ng-hide {
    display: none !important;
}
</style>
</head>
<body id="body">
<nav class="navbar navbar-default" ng-controller="NavCtrl">
    <div class="container-fluid">
        <div class="navbar-header">
            <a class="navbar-brand" href="#/">AngularJS Example</a>
        </div>
        <div class="collapse navbar-collapse" ng-cloak ng-show="user">
            <ul class="nav navbar-nav">
                <li><a href="#/">Home</a></li>
                <li><a href="#/page1">Page1</a></li>
                <li><a href="#/page2">Page2</a></li>
            </ul>
            <form class="navbar-form navbar-right">
                <button type="button" class="btn btn-default" ng-click="logout()">Logout</button>
            </form>
            <div class="navbar-text navbar-right">
                {{user.username}}
            </div>
        </div>
    </div>
</nav>
<div class="container-fluid">
    <div ng-view></div>
</div>
<script src="lib/jquery/dist/jquery.js"></script>
<script src="lib/angular/angular.js"></script>
<script src="lib/angular-route/angular-route.js"></script>
<script src="js/app.js"></script>
</body>
</html>
```

body にはまず最初にナビゲーションがあります。
ナビゲーションはログインの前後で表示する内容が異なるため NavCtrl コントローラーで制御します。
ログイン後であれば、各ページヘのリンク、ユーザー名、ログアウトボタン、などを表示します。
未ログインであればそれらが表示されないように `ng-show` を利用しています。

ナビゲーションの次に ngView があります。
ここには未ログインであればログインページを表示し、ログイン済みならなにかしらのコンテンツを表示します。

## login.html

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

```html:login.html
<form class="form-inline" ng-submit="login()">
    <input type="text" class="form-control" placeholder="username" ng-model="username">
    <input type="password" class="form-control" placeholder="password" ng-model="password">
    <button type="submit" class="btn btn-default" ng-disabled="disabled">Login</button>
    <span class="text-danger" ng-show="alert">{{alert.msg}}</span>
</form>
```

## ルーティング

js でルーティングを定義します。otherwise を除いて全部で４つのルートを定義しています。

/login がログインページのルート、その他の３つはログイン後のページです。

ログインページ以外のルートはコントローラーやテンプレートを別に用意するのが面倒なので手抜きです。

```js:app.js
var app = angular.module('App', ['ngRoute']);

app.config(function($routeProvider){
    $routeProvider.when('/', {template: '<h1>home</h1>', controller: function(){}});
    $routeProvider.when('/page1', {template: '<h1>page1</h1>', controller: function(){}});
    $routeProvider.when('/page2', {template: '<h1>page2</h1>', controller: function(){}});
    $routeProvider.when('/login', {templateUrl: 'view/login.html', controller: 'LoginCtrl'});
    $routeProvider.otherwise({redirectTo: '/'});
});
```

## 認証

認証のためのサービスを定義します。

認証はユーザー名とパスワードに同じ値を入れれば通ります。というか、なにも入力しなくても通ります。

本当に実装するときは非同期の問合せになるので、500 ミリ秒遅延する Promise を返しています。

```js:app.js
app.factory('AuthService', function($q, $timeout){
    var _user = null;
    return {
        isLogged: function(){ return !!_user; },
        getUser: function(){ return _user; },
        login: function(username, password){
            var deferred = $q.defer();
            $timeout(function(){
                if (username == password)
                {
                    _user = {username: username};
                    deferred.resolve();
                }
                else
                {
                    deferred.reject();
                }
            }, 500);
            return deferred.promise;
        },
        logout: function(){
            _user = null;
            return $q.all();
        }
    };
});
```

## \$routeChangeStart イベント

次にモジュールの run で `$rootScope` に `$routeChangeStart` イベントをバインドします。

このイベントは ngRoute のルーティングの開始時に呼ばれます。

これから表示しようとしているルートのコントローラーが LoginCtrl であるかどうか、ログイン済みであるかどうか、を元に必要に応じて適切なルートにリダイレクトします。

```js:app.js
app.run(function($rootScope, $location, $route, AuthService){
    $rootScope.$on('$routeChangeStart', function(ev, next, current){
        if (next.controller == 'LoginCtrl')
        {
            if (AuthService.isLogged())
            {
                $location.path('/');
                $route.reload();
            }
        }
        else
        {
            if (AuthService.isLogged() == false)
            {
                $location.path('/login');
                $route.reload();
            }
        }
    });
});
```

## ログインコントローラー

ログインコントローラーを作ります。

AuthService.login は Promise を返すので、成功時はトップにリダイレクト、失敗時はメッセージを表示します。

さらに失敗/成功に関わらずフォームをクリアしていますが、よく考えると成功時はこの処理必要ありませんでした。

```js:app.js
app.controller('LoginCtrl', function($scope, $location, AuthService){
    $scope.login = function(){
        $scope.disabled = true;
        AuthService.login($scope.username, $scope.password)
            .then(function(){
                $location.path('/');
            })
            .catch(function(){
                $scope.alert = {msg: "Login failed"};
            })
            .finally(function(){
                $scope.username = "";
                $scope.password = "";
                $scope.disabled = false;
            })
        ;
    };
});
```

## ナビコントローラー

最後にナビコントローラーを作ります。

ログイン状態は AuthService サービスが持っているので `$watch` で監視してナビコントローラーのスコープに設定します。

また、ログアウトでログインページにリダイレクトします。

```js:app.js
app.controller('NavCtrl', function($scope, $location, AuthService) {
    $scope.$watch(
        function(){ return AuthService.getUser() },
        function(newVal, oldVal){ $scope.user = newVal }
    );
    $scope.logout = function () {
        AuthService.logout().finally(function(){ $location.path('/login') });
    };
});
```

## 問題点？

"/" のルートに home.html などのテンプレートを使うと判るのですが、一番最初にサイトに訪れてログインページが表示されるとき、home.html がダウンロードされています。

`$routeChangeStart` イベントが呼ばれる時点ではまだテンプレートのダウンロードは開始されていないのですが、そのタイミングで `$location.path()` で遷移させてもテンプレートのダウンロードは止められないようです。

特に何が困るわけでもありませんが、なんとなく気持ち悪いです。

## \$routeProvider の resolve での実装

\$routeChangeStart イベントは使わずに（app.run をごっそり削除）して、ルーティングを次のようにする方法も考えられます。

```js
app.config(function($routeProvider){
    var requireAuth = {
        login: function($q, $location, AuthService){
            if (AuthService.isLogged() == false)
            {
                $location.path('/login');
                return $q.reject();
            }
        }
    };
    var skipLogin = {
        login: function($q, $location, AuthService){
            if (AuthService.isLogged())
            {
                $location.path('/');
                return $q.reject();
            }
        }
    };
    $routeProvider.when('/', {
        template: '<h1>home</h1>',
        controller: function(){},
        resolve: requireAuth
    });
    $routeProvider.when('/page1', {
        template: '<h1>page1</h1>',
        controller: function(){},
        resolve: requireAuth
    });
    $routeProvider.when('/page2', {
        template: '<h1>page2</h1>',
        controller: function(){},
        resolve: requireAuth
    });
    $routeProvider.when('/login', {
        templateUrl: 'view/login.html',
        controller: 'LoginCtrl',
        resolve: skipLogin
    });
    $routeProvider.otherwise({
        redirectTo: '/'
    });
});
```

## ログイン時にルーティングを再設定する実装

あるいは、ログアウトは実際にブラウザをリロードするという前提で、ログイン後にルーティングを再設定する方法も考えられます。

```js
app.config(function($routeProvider){
    // 最初は otherwise な LoginCtrl のルートしかない
    $routeProvider.otherwise({
        templateUrl: 'view/login.html',
        controller: 'LoginCtrl',
        resolve: {
            login: function($route, AuthService){
                if (AuthService.isLogged())
                {
                    // ログインに成功したらルートを追加＆上書きする
                    $routeProvider.when('/', {
                        template: '<h1>home</h1>', controller: function(){}
                    });
                    $routeProvider.when('/page1', {
                        template: '<h1>page1</h1>', controller: function(){}
                    });
                    $routeProvider.when('/page2', {
                        template: '<h1>page2</h1>', controller: function(){}
                    });
                    $routeProvider.otherwise({
                        redirectTo: '/'
                    });
                    $route.reload();
                }
            }
        }
    });
});
```

```js
// 不要
// app.run();
```

```js
app.controller('LoginCtrl', function($scope, $route, AuthService){
    $scope.login = function(){
        $scope.disabled = true;
        AuthService.login($scope.username, $scope.password)
            .then(function(){
                // ログインに成功したらルーティングを呼ぶ
                $route.reload();
            })
            .catch(function(){
                $scope.alert = {msg: "Login failed"};
            })
            .finally(function(){
                $scope.username = "";
                $scope.password = "";
                $scope.disabled = false;
            })
        ;
    };
});
```

```js
app.controller('NavCtrl', function($scope, $window, AuthService) {
    $scope.$watch(
        function(){ return AuthService.getUser() },
        function(newVal, oldVal){ $scope.user = newVal }
    );
    $scope.logout = function () {
        AuthService.logout().finally(function(){
            // ログアウトはブラウザのリロード
            // ($routeProvider に追加したルートをクリアする他の方法がわからない)
            $window.location.reload();
        });
    };
});
```

この方法なら `/page1` とかをダイレクトに開いた場合でも、ログインページを挟んだあとに `/page1` を表示することが出来ます。

 - デモ - [http://jsbin.com/dojoq/4/](http://jsbin.com/dojoq/4/)