AngularJS身份验证+RESTful API

71

使用Angular和RESTful API进行客户端通信,实现身份验证/路由控制

这个问题已经在几个不同的问题和教程中讨论过,但是我之前遇到的所有资源似乎都没有完全解决我的问题。

总的来说,我需要:

  • 通过从http://client.foo 发送 POST 请求到 http://api.foo/login 来登录
  • 为用户提供"已登录"的 GUI/组件状态,并提供logout路由
  • 当用户退出登录时,能够更新UI。这是最令人沮丧的部分。
  • 保护我的路由以检查认证状态(如果需要),并相应地将用户重定向到登录页面

我的问题点:

  • 每次导航到一个不同的页面时,我需要调用api.foo/status来确定用户是否已登录。(目前我正在使用Express来处理路由) 这会导致Angular决定像ng-show="user.is_authenticated"这样的东西时出现问题。
  • 成功登录/注销时,我需要刷新页面 (我不想这样做),以便填充{{user.first_name}}等内容。或者在注销时清空该值。
// Sample response from `/status` if successful 

{
   customer: {...},
   is_authenticated: true,
   authentication_timeout: 1376959033,
   ...
}

我尝试过的方法

我为什么感到疯狂

  • 似乎每个教程都依赖一些数据库(大量的Mongo、Couch、PHP+MySQL等),没有仅通过与RESTful API通信来保留已登录状态的解决方案。一旦登录,将使用withCredentials:true发送附加的POSTs/GETs,因此这不是问题。
  • 我找不到任何示例/教程/存储库,它们使用Angular+REST+Auth,但不需要后端语言。

我并不是太自豪

诚然,我对Angular还很陌生,如果我以荒谬的方式进行了尝试,那么可以提出替代方案——即使它从头到尾都是如此。

我主要使用Express,因为我真的很喜欢JadeStylus,如果我想做的事情只能通过Angular路由来实现,我就不会坚持使用Express的路由。

感谢提供任何帮助的人。请不要让我谷歌它,因为我有大约26页的紫色链接。;-)


1此解决方案依赖于Angular的$httpBackend模拟,不清楚如何让它与真实服务器通信。

2这是最接近的方法,但由于我需要验证身份的现有API,我无法使用passport的'localStrategy',而编写一个OAUTH服务似乎很疯狂...只有我打算使用它。


1
我没有使用$cookie$cookieStore。服务器创建的cookie会被返回并存储在浏览器中,当我进行其他需要身份验证的REST调用时,我在我的调用中设置withCredentials:true。这很好用,它可以维护已登录状态,并保护/隐藏路由,直到该用户登录为止,但我遇到了一些问题。 - couzzi
你是说你无法控制你的身份验证 API 吗?当用户访问你的 Express 路由后,你又会调用另一个 REST 服务进行身份验证吗? - BoxerBucks
1
请查看此链接:http://jonsamwell.com/url-route-authorization-and-security-in-angular/ - Jon
1
@JonSamwell - 这太棒了,这个帖子等了将近一年的答案。请将其列为答案并提供有关详细信息/代码的快速概述,我会将其标记为已接受。非常感谢。 - couzzi
1
@couzzi - 看看我的回答吧 - 我真的很高兴它有帮助到你。 - Jon
显示剩余6条评论
4个回答

35

这段内容摘自我的博客文章,主题是 URL 路由授权和元素安全性,链接在这里,但我将简要总结主要观点:

前端 Web 应用程序中的安全性仅是阻止普通用户访问的起始措施,但任何具备一定网络知识的用户都可以绕过它,因此您应始终具备服务器端安全性。

在 Angular 中,安全性最主要关注的是路由安全性。幸运的是,在定义 Angular 的路由时,您正在创建一个对象,该对象可以具有其他属性。 我的方法的基石是为此路由对象添加一个安全性对象,它基本上定义了用户必须拥有的角色,以便能够访问特定路由。

 // route which requires the user to be logged in and have the 'Admin' or 'UserManager' permission
    $routeProvider.when('/admin/users', {
        controller: 'userListCtrl',
        templateUrl: 'js/modules/admin/html/users.tmpl.html',
        access: {
            requiresLogin: true,
            requiredPermissions: ['Admin', 'UserManager'],
            permissionType: 'AtLeastOne'
        });
整个方法的重点是授权服务,该服务基本上检查用户是否具有所需权限。此服务将关注点从解决方案中与用户及其实际权限相关的其他部分中抽象出来,在登录期间从服务器检索到的权限。虽然代码相当冗长,但在我的博客文章中有全面的解释。然而,它基本上处理权限检查和两种授权模式。第一种是用户必须至少具有一个定义的权限,第二种是用户必须具有所有定义的权限。
angular.module(jcs.modules.auth.name).factory(jcs.modules.auth.services.authorization, [  
'authentication',  
function (authentication) {  
 var authorize = function (loginRequired, requiredPermissions, permissionCheckType) {
    var result = jcs.modules.auth.enums.authorised.authorised,
        user = authentication.getCurrentLoginUser(),
        loweredPermissions = [],
        hasPermission = true,
        permission, i;

    permissionCheckType = permissionCheckType || jcs.modules.auth.enums.permissionCheckType.atLeastOne;
    if (loginRequired === true && user === undefined) {
        result = jcs.modules.auth.enums.authorised.loginRequired;
    } else if ((loginRequired === true && user !== undefined) &&
        (requiredPermissions === undefined || requiredPermissions.length === 0)) {
        // Login is required but no specific permissions are specified.
        result = jcs.modules.auth.enums.authorised.authorised;
    } else if (requiredPermissions) {
        loweredPermissions = [];
        angular.forEach(user.permissions, function (permission) {
            loweredPermissions.push(permission.toLowerCase());
        });

        for (i = 0; i < requiredPermissions.length; i += 1) {
            permission = requiredPermissions[i].toLowerCase();

            if (permissionCheckType === jcs.modules.auth.enums.permissionCheckType.combinationRequired) {
                hasPermission = hasPermission && loweredPermissions.indexOf(permission) > -1;
                // if all the permissions are required and hasPermission is false there is no point carrying on
                if (hasPermission === false) {
                    break;
                }
            } else if (permissionCheckType === jcs.modules.auth.enums.permissionCheckType.atLeastOne) {
                hasPermission = loweredPermissions.indexOf(permission) > -1;
                // if we only need one of the permissions and we have it there is no point carrying on
                if (hasPermission) {
                    break;
                }
            }
        }

        result = hasPermission ?
                 jcs.modules.auth.enums.authorised.authorised :
                 jcs.modules.auth.enums.authorised.notAuthorised;
    }

    return result;
};

既然路由有了安全性,你需要一种方法来确定当路由更改已启动时用户是否可以访问该路由。为此,我们将拦截路由更改请求,检查路由对象(带有我们的新访问对象),如果用户无法访问视图,则用另一个路由替换该路由。

angular.module(jcs.modules.auth.name).run([  
    '$rootScope',
    '$location',
    jcs.modules.auth.services.authorization,
    function ($rootScope, $location, authorization) {
        $rootScope.$on('$routeChangeStart', function (event, next) {
            var authorised;
            if (next.access !== undefined) {
                authorised = authorization.authorize(next.access.loginRequired,
                                                     next.access.permissions,
                                                     next.access.permissionCheckType);
                if (authorised === jcs.modules.auth.enums.authorised.loginRequired) {
                    $location.path(jcs.modules.auth.routes.login);
                } else if (authorised === jcs.modules.auth.enums.authorised.notAuthorised) {
                    $location.path(jcs.modules.auth.routes.notAuthorised).replace();
                }
            }
        });
    }]);
关键在于'.replace()',因为它替换了当前路线(他们没有权限查看的那个路线),并将其重定向到我们要重定向的路线。这样就可以防止回退到未经授权的路线。
现在我们可以拦截路由,包括在登录后重定向用户到需要登录的路由。
解决方案的第二部分是根据用户权限隐藏/显示UI元素。这通过一个简单的指令实现。
angular.module(jcs.modules.auth.name).directive('access', [  
        jcs.modules.auth.services.authorization,
        function (authorization) {
            return {
              restrict: 'A',
              link: function (scope, element, attrs) {
                  var makeVisible = function () {
                          element.removeClass('hidden');
                      },
                      makeHidden = function () {
                          element.addClass('hidden');
                      },
                      determineVisibility = function (resetFirst) {
                          var result;
                          if (resetFirst) {
                              makeVisible();
                          }

                          result = authorization.authorize(true, roles, attrs.accessPermissionType);
                          if (result === jcs.modules.auth.enums.authorised.authorised) {
                              makeVisible();
                          } else {
                              makeHidden();
                          }
                      },
                      roles = attrs.access.split(',');


                  if (roles.length > 0) {
                      determineVisibility(true);
                  }
              }
            };
        }]);

然后您可以像这样使用元素:

 <button type="button" access="CanEditUser, Admin" access-permission-type="AtLeastOne">Save User</button>

阅读我的完整博客文章,了解更详细的方法概述。


5

我为UserApp编写了一个AngularJS模块,几乎可以完成您所要求的所有功能。 您可以选择:

  1. 修改模块并将函数附加到您自己的API上,或者
  2. 与用户管理API UserApp一起使用该模块

https://github.com/userapp-io/userapp-angular

它支持受保护/公共路由,登录/注销时的重定向,心跳状态检查,将会话令牌存储在cookie中,事件等等。
如果您想尝试UserApp,请参加Codecademy上的课程
以下是其工作原理的一些示例:
  • Login form with error handling:

    <form ua-login ua-error="error-msg">
        <input name="login" placeholder="Username"><br>
        <input name="password" placeholder="Password" type="password"><br>
        <button type="submit">Log in</button>
        <p id="error-msg"></p>
    </form>
    
  • Signup form with error handling:

    <form ua-signup ua-error="error-msg">
      <input name="first_name" placeholder="Your name"><br>
      <input name="login" ua-is-email placeholder="Email"><br>
      <input name="password" placeholder="Password" type="password"><br>
      <button type="submit">Create account</button>
      <p id="error-msg"></p>
    </form>
    
  • How to specify which routes that should be public, and which route that is the login form:

    $routeProvider.when('/login', {templateUrl: 'partials/login.html', public: true, login: true});
    $routeProvider.when('/signup', {templateUrl: 'partials/signup.html', public: true});
    

    The .otherwise() route should be set to where you want your users to be redirected after login. Example:

    $routeProvider.otherwise({redirectTo: '/home'});

  • Log out link:

    <a href="#" ua-logout>Log Out</a>

    (Ends the session and redirects to the login route)

  • Access user properties:

    User info is accessed using the user service, e.g: user.current.email

    Or in the template: <span>{{ user.email }}</span>

  • Hide elements that should only be visible when logged in:

    <div ng-show="user.authorized">Welcome {{ user.first_name }}!</div>

  • Show an element based on permissions:

    <div ua-has-permission="admin">You are an admin</div>

要对后端服务进行身份验证,只需使用user.token()获取会话令牌,并将其与AJAX请求一起发送。在后端,使用UserApp API(如果您使用UserApp)检查令牌是否有效。
如果需要任何帮助,请告诉我 :)

16
这是一个收费的解决方案,对吗? - couzzi
1
我建议,如果你要外包用户认证,最好选择付费解决方案(因为这意味着有法律约束力)。至于是否将这样一个关键组件委托给第三方,则是另一回事... - TK-421
UserApp 现在似乎已经停止运营了。 - oleksii

5

我一直没有使用 $resource , 因为我正在手动创建应用程序的服务调用。然而,我通过创建一个依赖于所有其他服务并获取某些初始化数据的服务来处理登录。 当登录成功时,它将触发所有服务的初始化。

在我的控制器范围内,我监听 loginServiceInformation 并相应地填充模型的一些属性(以触发适当的 ng-show/hide)。 关于路由,我正在使用 Angular 的内置路由,并且我只是根据 loggedIn 布尔值有一个 ng-hide,如下所示,它显示请求登录的文本或具有 ng-view 属性的 div(因此,如果没有立即在登录后登录,则在正确的页面上,目前我为所有视图加载数据,但我认为如果必要,这可以更加选择性)

//Services
angular.module("loginModule.services", ["gardenModule.services",
                                        "surveyModule.services",
                                        "userModule.services",
                                        "cropModule.services"
                                        ]).service(
                                            'loginService',
                                            [   "$http",
                                                "$q",
                                                "gardenService",
                                                "surveyService",
                                                "userService",
                                                "cropService",
                                                function (  $http,
                                                            $q,
                                                            gardenService,
                                                            surveyService,
                                                            userService,
                                                            cropService) {

    var service = {
        loginInformation: {loggedIn:false, username: undefined, loginAttemptFailed:false, loggedInUser: {}, loadingData:false},

        getLoggedInUser:function(username, password)
        {
            service.loginInformation.loadingData = true;
            var deferred = $q.defer();

            $http.get("php/login/getLoggedInUser.php").success(function(data){
                service.loginInformation.loggedIn = true;
                service.loginInformation.loginAttemptFailed = false;
                service.loginInformation.loggedInUser = data;

                gardenService.initialize();
                surveyService.initialize();
                userService.initialize();
                cropService.initialize();

                service.loginInformation.loadingData = false;

                deferred.resolve(data);
            }).error(function(error) {
                service.loginInformation.loggedIn = false;
                deferred.reject(error);
            });

            return deferred.promise;
        },
        login:function(username, password)
        {
            var deferred = $q.defer();

            $http.post("php/login/login.php", {username:username, password:password}).success(function(data){
                service.loginInformation.loggedInUser = data;
                service.loginInformation.loggedIn = true;
                service.loginInformation.loginAttemptFailed = false;

                gardenService.initialize();
                surveyService.initialize();
                userService.initialize();
                cropService.initialize();

                deferred.resolve(data);
            }).error(function(error) {
                service.loginInformation.loggedInUser = {};
                service.loginInformation.loggedIn = false;
                service.loginInformation.loginAttemptFailed = true;
                deferred.reject(error);
            });

            return deferred.promise;
        },
        logout:function()
        {
            var deferred = $q.defer();

            $http.post("php/login/logout.php").then(function(data){
                service.loginInformation.loggedInUser = {};
                service.loginInformation.loggedIn = false;
                deferred.resolve(data);
            }, function(error) {
                service.loginInformation.loggedInUser = {};
                service.loginInformation.loggedIn = false;
                deferred.reject(error);
            });

            return deferred.promise;
        }
    };
    service.getLoggedInUser();
    return service;
}]);

//Controllers
angular.module("loginModule.controllers", ['loginModule.services']).controller("LoginCtrl", ["$scope", "$location", "loginService", function($scope, $location, loginService){

    $scope.loginModel = {
                        loadingData:true,
                        inputUsername: undefined,
                        inputPassword: undefined,
                        curLoginUrl:"partials/login/default.html",
                        loginFailed:false,
                        loginServiceInformation:{}
                        };

    $scope.login = function(username, password) {
        loginService.login(username,password).then(function(data){
            $scope.loginModel.curLoginUrl = "partials/login/logoutButton.html";
        });
    }
    $scope.logout = function(username, password) {
        loginService.logout().then(function(data){
            $scope.loginModel.curLoginUrl = "partials/login/default.html";
            $scope.loginModel.inputPassword = undefined;
            $scope.loginModel.inputUsername = undefined;
            $location.path("home");
        });
    }
    $scope.switchUser = function(username, password) {
        loginService.logout().then(function(data){
            $scope.loginModel.curLoginUrl = "partials/login/loginForm.html";
            $scope.loginModel.inputPassword = undefined;
            $scope.loginModel.inputUsername = undefined;
        });
    }
    $scope.showLoginForm = function() {
        $scope.loginModel.curLoginUrl = "partials/login/loginForm.html";
    }
    $scope.hideLoginForm = function() {
        $scope.loginModel.curLoginUrl = "partials/login/default.html";
    }

    $scope.$watch(function(){return loginService.loginInformation}, function(newVal) {
        $scope.loginModel.loginServiceInformation = newVal;
        if(newVal.loggedIn)
        {
            $scope.loginModel.curLoginUrl = "partials/login/logoutButton.html";
        }
    }, true);
}]);

angular.module("loginModule", ["loginModule.services", "loginModule.controllers"]);

The HTML

<div style="height:40px;z-index:200;position:relative">
    <div class="well">
        <form
            ng-submit="login(loginModel.inputUsername, loginModel.inputPassword)">
            <input
                type="text"
                ng-model="loginModel.inputUsername"
                placeholder="Username"/><br/>
            <input
                type="password"
                ng-model="loginModel.inputPassword"
                placeholder="Password"/><br/>
            <button
                class="btn btn-primary">Submit</button>
            <button
                class="btn"
                ng-click="hideLoginForm()">Cancel</button>
        </form>
        <div
            ng-show="loginModel.loginServiceInformation.loginAttemptFailed">
            Login attempt failed
        </div>
    </div>
</div>

使用上述部分完成图片的基本 HTML 代码如下:
<body ng-controller="NavigationCtrl" ng-init="initialize()">
        <div id="outerContainer" ng-controller="LoginCtrl">
            <div style="height:20px"></div>
            <ng-include src="'partials/header.html'"></ng-include>
            <div  id="contentRegion">
                <div ng-hide="loginModel.loginServiceInformation.loggedIn">Please login to continue.
                <br/><br/>
                This new version of this site is currently under construction.
                <br/><br/>
                If you need the legacy site and database <a href="legacy/">click here.</a></div>
                <div ng-view ng-show="loginModel.loginServiceInformation.loggedIn"></div>
            </div>
            <div class="clear"></div>
            <ng-include src="'partials/footer.html'"></ng-include>
        </div>
    </body>

我已经定义了一个登录控制器,这个控制器在DOM中较高的位置,以便根据loggedIn变量更改页面的body区域。

请注意,我还没有实现表单验证。另外,由于我对Angular仍然很新手,所以欢迎对本文中的问题提出任何指导意见。虽然这并没有直接回答问题,因为它不是基于RESTful的实现,但我相信可以将相同的方法适应于$resources,因为它建立在$http调用之上。


这看起来非常有前途。我明天会试一下。谢谢!不过,我有一个问题——你能详细说明一下这一点吗?:我已经在DOM中定义了登录控制器,以便根据loggedIn变量更改页面的主体区域。 - couzzi
是的,我最终编辑并粘贴了帖子底部的那一部分,“Base HTML”是我所指的,在body标签中,我有一个“NavigationCtrl”,它处理导航并是我的“顶级控制器”。然后您可以看到LoginCtrl定义在我的“outerContainer”上,这是包装其他所有内容的div。这样,我可以在任何子DOM元素(在这种情况下基本上是任何地方)中使用它的作用域变量。 - shaunhusain

4
我已经创建了一个Github仓库,总结了这篇文章的基本内容:https://medium.com/opinionated-angularjs/techniques-for-authentication-in-angularjs-applications-7bbf0346acec ng-login Github 仓库 Plunker 我尽可能地解释清楚,希望能帮助你们中的一些人:
(1) app.js: 在应用程序定义中创建身份验证常量。
var loginApp = angular.module('loginApp', ['ui.router', 'ui.bootstrap'])
/*Constants regarding user login defined here*/
.constant('USER_ROLES', {
    all : '*',
    admin : 'admin',
    editor : 'editor',
    guest : 'guest'
}).constant('AUTH_EVENTS', {
    loginSuccess : 'auth-login-success',
    loginFailed : 'auth-login-failed',
    logoutSuccess : 'auth-logout-success',
    sessionTimeout : 'auth-session-timeout',
    notAuthenticated : 'auth-not-authenticated',
    notAuthorized : 'auth-not-authorized'
})

(2) Auth Service: 所有下列函数均在 auth.js 服务中实现。使用 $http 服务与服务器通信进行身份验证流程。还包含授权功能,即确定用户是否可以执行某个操作。

angular.module('loginApp')
.factory('Auth', [ '$http', '$rootScope', '$window', 'Session', 'AUTH_EVENTS', 
function($http, $rootScope, $window, Session, AUTH_EVENTS) {

authService.login() = [...]
authService.isAuthenticated() = [...]
authService.isAuthorized() = [...]
authService.logout() = [...]

return authService;
} ]);

(3) 会话(Session): 用于保存用户数据的单例对象。其实现取决于您。

angular.module('loginApp').service('Session', function($rootScope, USER_ROLES) {

    this.create = function(user) {
        this.user = user;
        this.userRole = user.userRole;
    };
    this.destroy = function() {
        this.user = null;
        this.userRole = null;
    };
    return this;
});

(4) 父级控制器: 将其视为应用程序的“主”功能,所有控制器都继承自此控制器,它是此应用程序身份验证的支柱。

<body ng-controller="ParentController">
[...]
</body>

(5) 访问控制:要拒绝某些路由的访问,需要执行两个步骤:

a)向ui router的$stateProvider服务中添加允许访问每个路由的角色数据,如下所示(ngRoute也可以适用)。

.config(function ($stateProvider, USER_ROLES) {
  $stateProvider.state('dashboard', {
    url: '/dashboard',
    templateUrl: 'dashboard/index.html',
    data: {
      authorizedRoles: [USER_ROLES.admin, USER_ROLES.editor]
    }
  });
})

b) 在 $rootScope.$on('$stateChangeStart') 中添加函数,以防止未经授权的用户改变状态。

$rootScope.$on('$stateChangeStart', function (event, next) {
    var authorizedRoles = next.data.authorizedRoles;
    if (!Auth.isAuthorized(authorizedRoles)) {
      event.preventDefault();
      if (Auth.isAuthenticated()) {
        // user is not allowed
        $rootScope.$broadcast(AUTH_EVENTS.notAuthorized);
      } else {d
        // user is not logged in
        $rootScope.$broadcast(AUTH_EVENTS.notAuthenticated);
      }
    }
});

(6) Auth拦截器:这个已经实现了,但是无法在此代码范围内进行检查。每次$http请求后,该拦截器会检查状态码,如果返回以下任何一个,则广播一个事件强制用户再次登录。

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

附言:如第一篇文章所述,表单数据自动填充的错误可以通过在directives.js中添加指令来轻松避免。

附言2:用户可以轻松地调整此代码,以允许查看不同的路由,或显示不应显示的内容。逻辑必须在服务器端实现,这只是一种在ng-app上正确显示内容的方法。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接