Angularjs, Authentication, and Pyramid: More Ducks
Authentication is always difficult in web application programming. A lot of machinery, often from disparate sources, has to come together all at once. This post outlines getting this all working together for Pyramid and Angularjs. The standard Pyramid AuthTkt will be used.
Pyramid's authentication machinery works as follows: if a resource is accessed that requires authorization, the first step of the process is to authenticate the user. If the user is not currently logged in, Pyramid responds by raising/returning a 403 (Forbidden) exception. Normally, it is expected that a "Forbidden View" will be configured and displayed. The user will attempt login. If login fails, the Forbidden View will usually be re-presented, until login succeeds or the user gives up. The key fact being emphasized here is that the process begins with a 403 response. (I am simplifying here, Pyramid deals with "principals", rather than userids. It is possible to define a callback to extend the principal(s), which is most commonly done to add group or capability information for the current request to the principal, usually a userid. The details are available in the narrative doumentation chapter on Security and the authentication module, AuthTktAuthenticationPolicy is being used in this discussion.) Since this discussion focusses on getting Pyramid and Angularjs to cooperate in the login process, only the case of a simple userid with no group or capability information is presented.
The normal Pyramid Forbidden View is not used. All Pyramid will do is raise a 403 exception, and that ends its involvement with the login form. Angularjs will be responsible for noticing that the login form is needed, presenting it, submitting the result, and doing the client-side cleanup needed.
The two most well known Angularjs authentication systems both use an http interceptor to detect that login is needed. This is actually quite nice if you are using the timeout options of Pyramid AuthTkt, since it allows a login form to be presented to the user even in mid-post, without requiring a re-submission.
The first such system I found was: Witold Szczerba's angular-http-auth. A later implementation of a similar idea was presented by Stewart MacKenzie-Leigh in a blog post entitled "An AngularJS starter application with authentication". Unfortunately, his website is down, apparently permanently. The code is still available at: https://github.com/stewartml/angular-auth, however. This code is somewhat simpler than the Szczerba code, and the rest of this article is based it.
Another interesting article is Fredrick Nakstad's Authentication in Single Page Applications with Angular.js, which addresses changing page content with the authenticated user's role(s). This is beyond the scope of this article, but is worth reading.
Stewart MacKenzie-Leigh's code assumes that the server will respond with a with a 401 (Unauthorized). As mentioned above, Pyramid responds with 403 (Forbidden) to indicate that login is required. So, the question is which to adapt. I chose to adapt the JavaScript code. It would be equally easy to adapt Pyramid, however.
In principle, this is as easy as changing (response.status == 401) to (response.status == 403). There is a slight complication if you use JSONP, more about that later.
Also, the MacKenzie-Leigh code uses AngularUI to using a dialog widget. That widget is no longer available, so this code switches to a modal.
angular.module('login', []).config(['$httpProvider', function($httpProvider){ $httpProvider.responseInterceptors.push([ '$rootScope', '$q', function(scope, $q) { return function(promise) { return promise.then(success, error); }; function success(response) { return response; } function error(response) { if ((response.status === 403) || ((response.config.method === 'JSONP') && (response.status === 0))) { var deferred = $q.defer(); scope.failedRequests.push({ config: response.config, deferred: deferred }); scope.$broadcast('event:loginRequired'); return deferred.promise; } else { return $q.reject(response); } } } ]); }]) .run (['$rootScope', '$compile', '$http', '$modal', function($rootScope, $compile, $http, $modal){ $rootScope.failedRequests = []; $rootScope.loggedIn = false; var m; // slot for modal $rootScope.$on('event:loginRequired', function() { m = $modal.open({backdrop: true, templateUrl: 'partials/login.html', controller: LoginController}); }); $rootScope.$on('event:loginSuccess', function() { m.close(); for (var i=0; i<$rootScope.failedRequests.length; i++) { var request = $rootScope.failedRequests[i]; $http(request.config) .then(function(response) { request.deferred.resolve(response); }); } $rootScope.failedRequests = []; }); }]); function LoginController($scope, $http, $modal, $rootScope) { $scope.dta = {userid: '', password: '', errorMessage: ''}; $scope.login = function() { var params = {userid: $scope.dta.userid, password: $scope.dta.password}; $http({method: 'POST', url: '/scrap/login', params: params}) .success(function(data, status, error, config) { if (data === 'OK') { $rootScope.$broadcast('event:loginSuccess'); } else { $scope.dta.errorMessage = 'Invalid userid or password'; } }) .error(function(data, status, error, config) { $scope.dta.errorMessage = 'Backend problem login in. Contact IT.'; $rootScope.$broadcast('event:loginRequired'); }); }; } LoginController.inject = ['$scope', '$http', '$modal', '$rootScope'];
This is pretty simple, and works quite well. The only assumptions being made are that the login form can be found in partials/login.html, that it have fields userid, password, and errorMessage, and that there is a server-side (Pyramid) function that handles the actual login. In this code, the server side function should return 'OK' for an authorized user, and anything else for a bad userid/password. The login form should also have a errorMessage field to permit feedback to the user. If you need to adapt the presented pages per user's role(s), you might return the user's groups instead of OK, and return an explicit "FAIL" or something to indicate failure. Mutatis mutandus.
The $modal call in the loginRequired event comes from Angular UI Boostrap. So, obviously, this has to be downloaded and laced into your app.js, and index.html. Note that it is 'ui.boostrap' in your app declaration (I made the idiot mistake of trying ui.bootstrap.modal -- doesn't work!). Also, note that UI Boostrap requires bootstrap 2.3.2, at the time this post was (re-)written. Bootstrap 3 is not compatible yet.
The simplest version of the login form is something like:
<form ng-controller="LoginController" ng-submit="login();"> <div><label>Userid: </label><input type="text" ng-model="dta.userid"></div> <div><label>Password:</label> <input type="password" autocomplete="off" ng-model="dta.password"></div> <button ng-click="login();">Login</button> <br /> {{dta.errorMessage}} </form>
Now, the Pyramid authentication machinery will no longer be responsible for displaying the Forbidden View to the user. Instead we simply need to return the expected 403 code so the the interceptor is triggered, which will cause the login form to be displayed. The Pyramid code can look like this:
def login(request): if 'userid' in request.params and 'password' in request.params: userid = request.params['userid'] password = request.params['password'] if check_password(userid, password): headers = remember(request, userid) response = Response('') response.headers.extend(headers) return response return HTTPUnauthorized(detail='Bad Login') return HTTPUnauthorized(detail='Bad Login') def logout(request): headers = forget(request) return HTTPFound('/', headers=headers)
check_password is not shown in the above, as it is typically site-specific. It should check that the password supplied in the form hashes to the value that you have stored for this user, using whatever hash scheme you choose, whatever salt scheme you have, etc. This is actually a bit simpler than the stock Pyramid scheme. Logout is added as it makes testing easier, and may be required for many corporate policies, anyway.
An especially attentive reader might have noticed that the condition for presenting the login form is that the response status be 403 or that the response be for a JSONP request and have a 0 status. What's that about? It turns out that for JSONP, angularjs (and jquery) fail to properly propagate the response's status into response.status. Angularjs does correctly raise the error condition; and so the error function of the login module is entered. Reacting based on "JSONP" and response.statue == 0 is morally equivalent to a naked except clause in python: we know there is a problem, but have lost track of its cause, which is pretty bad. There is little choice for what will happen next. Any JSONP response that gets an HTTP Error response subsequently triggers the interceptor. The effect is that the user is asked to log in again. This is better than silently waiting for the user to do a refresh, but is not ideal, and may cause confusion. So, your JSONP methods had better be correct! To partially ameliorate this, I always do a trivial GET in my initial index.html. This gives a chance to present the user with better diagnostics, at least on first load.
I hope this will help someone. This can be hard to debug if anything goes wrong. Either it all works, or nothing works. Don't hesitate to contact me as jpenny @ jpenny.im or as jpenny in #pyramid on the FreeNode IRC server if you need help getting this combination to work.