AngularJS Authentication + RESTful API
Solution 1:
This is taken from my blog post on url route authorisation and element security here but I will briefly summaries the main points :-)
Security in frontend web application is merely a starting measure to stop Joe Public, however any user with some web knowledge can circumvent it so you should always have security server-side as well.
The main concern around security stuff in angular is route security, luckily when defining a route in angular you are create an object, an object that can have other properties. The cornerstone to my approach is to add a security object to this route object which basically defines the roles the user must be in to be able to access a particular route.
// 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'
});
The whole approach focuses around an authorisation service which basically does the check to see if the user has the required permissions. This service abstract the concerns away from the other parts of this solution to do with the user and their actual permission that would have been retrieved from the server during login. While the code is quite verbose it is fully explained in my blog post. However, it basically handle the permission check and two modes of authorisation. The first is that the user must have at least on of the defined permissions, the second is the user must have all of the defined permissions.
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;
};
Now that a route has security you need a way of determining if a user can access the route when a route change has been started. To do this we be intercepting the route change request, examining the route object (with our new access object on it) and if the user cannot access the view we replace the route with another one.
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();
}
}
});
}]);
The key here really is the '.replace()' as this replace the current route (the one they have not got rights to see) with the route we are redirecting them to. This stop any then navigating back to the unauthorised route.
Now we can intercept routes we can do quite a few cool things including redirecting after a login if a user landed on a route that they needed to be logged in for.
The second part of the solution is being able to hide/show UI element to the user depending on there rights. This is achieve via a simple directive.
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);
}
}
};
}]);
You would then sure an element like so:
<button type="button" access="CanEditUser, Admin" access-permission-type="AtLeastOne">Save User</button>
Read my full blog post for a much more detailed overview to the approach.
Solution 2:
I haven't been using $resource because I'm just hand crafting my service calls for my application. That said I've handled login by having a service which depends on all the other services that get some sort of initialization data. When the login succeeds it triggers for initialization of all the services.
Within my controller scope I watch the loginServiceInformation and populate some properties of the model accordingly (to trigger the appropriate ng-show/hide). With regard to routing I'm using Angular's built in routing and I simply have an ng-hide based on the loggedIn boolean shown here, it shows text to request login or else the div with the ng-view attribute (so if not logged in immediately after login you're on the correct page, currently I load data for all views but I believe this could be more selective if necessary)
//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>
The Base HTML that uses the parts above to complete the picture:
<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>
I have the login controller defined with an ng-controller higher up in the DOM so that I can change the body area of my page based on the loggedIn variable.
Note I haven't implemented form validation here yet. Also admittedly still quite fresh to Angular so any pointers to things in this post are welcome. Although this doesn't answer the question directly since it isn't a RESTful based implementation I believe the same can be adapted to $resources since it's built on top of $http calls.
Solution 3:
I've written an AngularJS module for UserApp that does pretty much everything you ask for. You could either:
- Modify the module and attach the functions to your own API, or
- Use the module together with the user management API, UserApp
https://github.com/userapp-io/userapp-angular
It supports protected/public routes, rerouting on login/logout, heartbeats for status checks, stores the session token in a cookie, events, etc.
If you want to give UserApp a try, take the course on Codecademy.
Here's some examples of how it works:
-
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>
And to authenticate to your back-end services, just use user.token()
to get the session token and send it with the AJAX request. At the back-end, use the UserApp API (if you use UserApp) to check if the token is valid or not.
If you need any help, just let me know :)
Solution 4:
I've created a github repo summing up this article basically: https://medium.com/opinionated-angularjs/techniques-for-authentication-in-angularjs-applications-7bbf0346acec
ng-login Github repo
Plunker
I'll try to explain as good as possible, hope I help some of you out there:
(1) app.js: Creation of authentication constants on app definition
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: All following functions are implemented in auth.js service. The $http service is used to communicate with the server for the authentication procedures. Also contains functions on authorization, that is if the user is allowed to perform a certain action.
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: A singleton to keep user data. The implementation here depends on you.
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) Parent controller: Consider this as the "main" function of your application, all controllers inherit from this controller, and it's the backbone of the authentication of this app.
<body ng-controller="ParentController">
[...]
</body>
(5) Access control: To deny access on certain routes 2 steps have to be implemented:
a) Add data of the roles allowed to access each route, on ui router's $stateProvider service as can be seen below (same can work for ngRoute).
.config(function ($stateProvider, USER_ROLES) {
$stateProvider.state('dashboard', {
url: '/dashboard',
templateUrl: 'dashboard/index.html',
data: {
authorizedRoles: [USER_ROLES.admin, USER_ROLES.editor]
}
});
})
b) On $rootScope.$on('$stateChangeStart') add the function to prevent state change if the user is not authorized.
$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 interceptor: This is implemented, but can't be checked on the scope of this code. After each $http request, this interceptor checks the status code, if one of the below is returned, then it broadcasts an event to force the user to log-in again.
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);
}
};
} ]);
P.S. A bug with the form data autofill as stated on the 1st article can be easily avoided by adding the directive that is included in directives.js.
P.S.2 This code can be easily tweaked by the user, to allow different routes to be seen, or display content that was not meant to be displayed. The logic MUST be implemented server-side, this is just a way to show things properly on your ng-app.