angularjs ionic实现用户认证

年后开始尝试用ionic来做个移动App,之前也列出了需要解决的问题,今天算是把UI都搞的差不多了,接着就把权限认证也搞上吧。
一通Google+乱问搜罗到了这篇文章,先不管行不行,直接翻译了再说(按照自己的意思翻译的),不行咱们再找别的方案。

原文地址 https://devdactic.com/user-auth-angularjs-ionic/

原作者说:在Ionic+AngularJS做的Hybrid App中实现登陆和用户认证系统是大家遇到的最难的问题之一,他之前写过一个简单的登陆simple login with Ionic and AngularJS居然是每天访问最多的(反正我没看到,这里是作者写的文章里被浏览最多的)。所以作者又有了奉献精神,当当当,写出了这篇完整的权限认证系统!在此小弟感谢,要不您的文章我还得再花几天时间捣鼓! O(∩_∩)O

认证系统嘛,从以下三个角度来一一解决

  1. 用户登陆和会话保持(后端基于token认证)。
  2. 基于路由的授权控制
  3. 会话失效,重新登陆

这里提供了一份完整的源代码 点击下载,一个基于Ionic框架开发的,但大多数只是AngularJS逻辑,所以不论你想要用到AngularJS App 还是 Hybrid App都没问题,接着往下看吧。还有需要一个后端程序,这是原作者关于后端写的一篇文章 RESTful API User Authentication with Node.js and AngularJS

准备工作

首先创建个Ionic项目,关于Ionic的开发环境可以看Ionic+Angularjs+PhoneGap开发环境搭建

$ ionic start devdactic-auth blank
$ cd devdactic-auth
$ bower install angular-mocks --save # 经测试这玩意不用装,没啥用,就是模拟请求的。

打开项目,找到www/js目录,创建3个文件,分别是controllers.js services.jsconstants.js

之后在index.html引入

<script src="js/controllers.js"></script>
<script src="js/services.js"></script>
<script src="js/constants.js"></script>
<!-- 
如果直接源码下载angular-mocks.js
并且放到了www/lib/ionic/js/angular/angular-mocks.js
那么就不用在此引入。
-->
<script src="lib/angular-mocks/angular-mocks.js"></script>

还要修改www/js/app.js文件,引入angular-mocks模块

angular.module('starter', ['ionic', 'ngMockE2E'])

再创建5个HTML文件放到 www/templates目录(没有此目录自行创建),分别是 login.html, main.html, dashboard.html, public.html, admin.html

OK,准备工作就这些,下面开始愉(苦)快(逼)的编码吧。

在APP里创建一些常量

在创建route之前,需要先定义一些在全局常量,这样比用事件广播或者字符串定义的角色要简单。 编辑www/js/constants.js文件

angular.module('starter')

.constant('AUTH_EVENTS', {
    notAuthenticated: 'auth-not-authenticated',
    notAuthorized: 'auth-not-authorized'
})

.constant('USER_ROLES', {
    admin: 'admin_role',
    public: 'public_role'
});

很简单,现在我们就可以在程序中使用这些常量了。并且看到了这个 AngularJS中的身份验证应用程序的技术, 感谢!

开始创建视图

程序包含一个登录页面和3个tab选项的布局,这些tab页都是需要身份验证,所以没登录前不能访问任何tab页面。就算在浏览器里运行,也不能直接通过URL来访问这些tab页面,这样才是确保你应用是最安全的。

编辑www/index.html的body标签

<body ng-app="starter" ng-controller="AppCtrl">
    <ion-nav-bar class="bar-balanced">
    </ion-nav-bar>
    <ion-nav-view></ion-nav-view>
</body>

我们有一个导航栏(ion-nav-bar标签)和导航视图(ion-nav-view)元素将显示视图连接到路由,为了让程序尽快运行就先在创建个全部空的controllers。

编辑www/js/controllers.js

angular.module('starter')

.controller('AppCtrl', function() {})
.controller('LoginCtrl', function() {})
.controller('DashCtrl', function() {});

接着是登录页面,就创建两个input+一个登录按钮。

编辑www/templates/login.html

<ion-view view-title="Sign-In" name="login-view">
    <ion-content class="padding">
        <div class="list list-inset">
            <label class="item item-input">
                <input type="text" placeholder="Username" ng-model="data.username">
            </label>
            <label class="item item-input">
                <input type="password" placeholder="Password" ng-model="data.password">
            </label>
        </div>
        <button class="button button-block button-positive" ng-click="login(data)">Login</button>
    </ion-content>
</ion-view>

下一个是登陆后的抽象视图,用于登录后的选项卡,并负责加载视图(这个只可意会,白话文我不太会讲,可能没太理解,有大神讲的比较清晰的跟俺留个言呗)。

这里解释一下angular-ui-router的 abstract(抽象视图)属性

我的理解是abstract抽象视图不能直接被激活,但是可以设置为被激活的子节点。

就像你在A页面填入一些内容,但内容太多,有一项需要新打开一个B页面去选择,如果现在直接跳转到B页面,填写完成后再回到A页面,你就会发现一些问题(像A页面原来填写内容没有了),那么这个时候你需要设置B页面为abstract: ture就不会再存在此问题了。

编辑www/templates/main.html

<ion-view>
    <ion-tabs class="tabs-icon-top tabs-balanced">

        <ion-tab title="Dashboard" icon="ion-home" href="#/main/dash">
            <ion-nav-view name="dash-tab"></ion-nav-view>
        </ion-tab>

        <ion-tab title="Public" icon="ion-earth" href="#/main/public">
            <ion-nav-view name="public-tab"></ion-nav-view>
        </ion-tab>

        <ion-tab title="Secret" icon="ion-nuclear" href="#/main/admin">
            <ion-nav-view name="admin-tab"></ion-nav-view>
        </ion-tab>

    </ion-tabs>
</ion-view>

当用户登陆后,将跳转到Dashboard页面,页面中包含一个提示文字,一个注销按钮和3个其他请求的按钮。现在还不能运行,因为控制器和services.js还没写好(快点呗,真啰嗦,天朝程序猿就喜欢简单粗暴直接的),但是view写好了,所以我们尽快看下我们程序吧(您也知道要快点了。。)

编辑www/templates/dashboard.html

<ion-view view-title="Dashboard" name="dashboard-view">
    <ion-nav-buttons side="right">
        <button class="button icon-left ion-log-out button-stable" ng-click="logout()">Logout</button>
    </ion-nav-buttons>
    <ion-content class="padding">
        <div class="row">
            <div class="col text-center">
                <h3 class="title">Welcome !</h3>
                This could be your Dashboard.
                <br><br><br>
                <button class="button button-full button-positive" ng-click="performValidRequest()">
                    Make Valid Request
                </button>
                <button class="button button-full button-energized" ng-click="performUnauthorizedRequest()">
                    Make Request for unauthorized resource
                </button>
                <button class="button button-full button-assertive" ng-click="performInvalidRequest()">
                    Make Request without valid token
                </button>
                <br><br>
                
            </div>
        </div>
    </ion-content>
</ion-view>

其他两个视图(public.html, admin.html)是基于用于角色保护的,所以视图只包含你看到的。
编辑 www/templates/public.html

<ion-view view-title="Public" name="dashboard-view">
    <ion-content class="padding">
        任何登陆的人都能看到!
    </ion-content>
</ion-view>

最后是这个www/templates/admin.html,并且只有管理员才能看到!

<ion-view view-title="Admin" name="dashboard-view">
    <ion-content class="padding">
        管理员才能看到的地方!
    </ion-content>
</ion-view>

全部view就这些了,到现在还没啥特别的,就是一般程序。主要还是后面的程序,咱们继续,下面是路由,算是跟程序的一个照面。

实现基本路由

这个demo的路由不是很复杂,一个登陆页面,一个有三个选项卡的页面,其中main这个抽象路由是抽象路线的模板,还有3个main.*对应的是3个选项页面。

这里主要介绍一个地方是37-39行,链接管理员选项卡。这里做了权限控制,只有当用户USER_ROLES.admin时才可以访问这些页面。现在还不能正常运行,咱们接着往下走。

编辑www/js/app.js

.config(function ($stateProvider, $urlRouterProvider, USER_ROLES) {
    $stateProvider
    .state('login', {
        url: '/login',
        templateUrl: 'templates/login.html',
        controller: 'LoginCtrl'
    })
    .state('main', {
        url: '/',
        abstract: true,
        templateUrl: 'templates/main.html'
    })
    .state('main.dash', {
        url: 'main/dash',
        views: {
            'dash-tab': {
                templateUrl: 'templates/dashboard.html',
                controller: 'DashCtrl'
            }
        }
    })
    .state('main.public', {
        url: 'main/public',
        views: {
            'public-tab': {
                templateUrl: 'templates/public.html'
            }
        }
    })
    .state('main.admin', {
        url: 'main/admin',
        views: {
            'admin-tab': {
                templateUrl: 'templates/admin.html'
            }
        },
        data: {
            authorizedRoles: [USER_ROLES.admin]
        }
    });

    // Thanks to Ben Noblet!
    $urlRouterProvider.otherwise(function ($injector, $location) {
        var $state = $injector.get("$state");
        $state.go("main.dash");
    });
})

现在程序里已经进入了angular-mocks,所以可以添加很多东西到程序。添加以下内容到www/js/app.js,咱们运行看看结果。(译者去掉所有angular-mocks的东西就成功了)

.run(function($httpBackend){
    $httpBackend.whenGET(/templates\/\w+.*/).passThrough();
})

权限认证简单demo

后端添加用户认证服务

存储服务端token或者session-id使用localStorage,请求时把token放在http的头$http.defaults.headers.common['X-Auth-Token'] = 'token'

编辑services.js

angular.module('starter')

.service('AuthService', function($q, $http, USER_ROLES) {
    var LOCAL_TOKEN_KEY = '';
    var username = '';
    var isAuthenticated = false; // 是否已授权
    var role = '';
    var authToken;

    function loadUserCredentials() {
        var token = window.localStorage.getItem(LOCAL_TOKEN_KEY);
        if (token) {
            useCredentials(token);
        }
    }

    function storeUserCredentials(token) {
        window.localStorage.setItem(LOCAL_TOKEN_KEY, token);
        useCredentials(token);
    }

    function useCredentials(token) {
        username = token.split('.')[0];
        isAuthenticated = true;
        authToken = token;

        if (username == 'admin') {
            role = USER_ROLES.admin
        }
        if (username == 'user') {
            role = USER_ROLES.public
        }

        // Set the token as header for your requests!
        $http.defaults.headers.common['X-Auth-Token'] = token;
    }

    function destroyUserCredentials() {
        authToken = undefined;
        username = '';
        isAuthenticated = false;
        $http.defaults.headers.common['X-Auth-Token'] = undefined;
        window.localStorage.removeItem(LOCAL_TOKEN_KEY);
    }

    var login = function(name, pw) {
        return $q(function(resolve, reject) {
            if ((name == 'admin' && pw == '1') || (name == 'user' && pw == '1')) {
                // Make a request and receive your auth token from your server
                storeUserCredentials(name + '.yourServerToken');
                resolve('Login success.');
            } else {
                reject('Login Failed.');
            }
        });
    };

    var logout = function() {
        destroyUserCredentials();
    };

    var isAuthorized = function(authorizedRoles) {
        if (!angular.isArray(authorizedRoles)) {
            authorizedRoles = [authorizedRoles];
        }
        return (isAuthenticated && authorizedRoles.indexOf(role) !== -1);
    };

    loadUserCredentials();

    return {
        login: login,
        logout: logout,
        isAuthorized: isAuthorized,
        isAuthenticated: function() {return isAuthenticated;},
        username: function() {return username;},
        role: function() {return role;}
    };
})

像之前说的,我们不能检查token是否有效,所以我们设置一个拦截器,当用一个无效的token请求,拦截器会注意到$http响应的错误,并且播放一条消息到app。

所以还要在services.js添加以下内容。

.factory('AuthInterceptor', function ($rootScope, $q, AUTH_EVENTS) {
    return {
        responseError: function (response) {
            $rootScope.$broadcast({
                401: AUTH_EVENTS.notAuthenticated,
                403: AUTH_EVENTS.notAuthorized
            }[response.status], response);
            return $q.reject(response);
        }
    };
})

.config(function ($httpProvider) {
    $httpProvider.interceptors.push('AuthInterceptor');
});

虽然这个请求是全局的配置,但依然需要控制器来配合

.controller('AppCtrl', function($scope, $state, $ionicPopup, AuthService, AUTH_EVENTS) {
    $scope.username = AuthService.username();

    $scope.$on(AUTH_EVENTS.notAuthorized, function(event) {
        var alertPopup = $ionicPopup.alert({
            title: 'Unauthorized!',
            template: 'You are not allowed to access this resource.'
        });
    });

    $scope.$on(AUTH_EVENTS.notAuthenticated, function(event) {
        AuthService.logout();
        $state.go('login');
        var alertPopup = $ionicPopup.alert({
            title: 'Session Lost!',
            template: 'Sorry, You have to login again.'
        });
    });

    $scope.setCurrentUsername = function(name) {
        $scope.username = name;
    };
})

notAuthorized事件告知用户权限不足,notAuthenticated事件是token过期需要登录等。之后又设置了个当前用户,这可以使用所有的子范围!

之后是LoginCtrl

.controller('LoginCtrl', function($scope, $state, $ionicPopup, AuthService) {
    $scope.data = {};

    $scope.login = function(data) {
        AuthService.login(data.username, data.password).then(function(authenticated) {
            $state.go('main.dash', {}, {reload: true});
            $scope.setCurrentUsername(data.username);
        }, function(err) {
            var alertPopup = $ionicPopup.alert({
                title: 'Login failed!',
                template: 'Please check your credentials!'
            });
        });
    };
})

接着是DashCtrl

.controller('DashCtrl', function($scope, $state, $http, $ionicPopup, AuthService) {
    $scope.logout = function() {
        AuthService.logout();
        $state.go('login');
    };

    $scope.performValidRequest = function() {
        $http.get('http://localhost:8100/valid').then(
            function(result) {
                $scope.response = result;
            });
    };

    $scope.performUnauthorizedRequest = function() {
        $http.get('http://localhost:8100/notauthorized').then(
            function(result) {
                // No result here..
            }, function(err) {
                $scope.response = err;
            });
    };

    $scope.performInvalidRequest = function() {
        $http.get('http://localhost:8100/notauthenticated').then(
            function(result) {
                // No result here..
            }, function(err) {
                $scope.response = err;
            });
    };
});

拦截所有改变的状态

现在没有任何检查用户的状态,这就意味着用户可以改变其URL来访问想要访问的页面。这时需要添加路由更改事件.

编辑 app.js 最下方添加

.run(function ($rootScope, $state, AuthService, AUTH_EVENTS) {
    $rootScope.$on('$stateChangeStart', function (event,next, nextParams, fromState) {
        if ('data' in next && 'authorizedRoles' in next.data) {
            var authorizedRoles = next.data.authorizedRoles;
            if (!AuthService.isAuthorized(authorizedRoles)) {
                event.preventDefault();
                $state.go($state.current, {}, {reload: true});
                $rootScope.$broadcast(AUTH_EVENTS.notAuthorized);
            }
        }
        if (!AuthService.isAuthenticated()) {
            if (next.name !== 'login') {
                event.preventDefault();
                $state.go('login');
            }
        }
    });
});

译者试验了,的确很好用,但是有些地方并不合意。待俺先加上后端服务,修改修改再上来更新吧!!