Recently I changed the security in SimplePVR, which is my spare-time pet project. SimplePVR is a typical Angular application, where the back-end is a Sinatra application exposing both the JavaScript for the Angular application and a set of REST services used by the Angular application.
I used to secure everything in the back-end with HTTP Basic Authentication, with no special handling in the Angular application. This worked well, but had some problems:
- The user was shown the browser's native log-in dialog, which is not pretty.
- There was no obvious way to "log out", i.e. clear the HTTP Basic credentials.
- Some browsers would show the log-in dialog for every single file fetched from the back-end, resulting in a very user-unfriendly experience.
Still, HTTP Basic Authentication is very nice for REST services, and adequately secure as long as you do HTTPS. I wanted to stick with HTTP Basic Authentication but give the user a better experience and a "log out" feature.
I thought that this had been done numerous times before, since Angular has great REST support, and HTTP Basic Authentication is widely used for REST services. It turned out to be much harder than I had anticipated, and hopefully this blog post will be useful to others. Join my odyssey...
About HTTP Basic Authentication
In case you don't know what HTTP Basic Authentication is all about, it works like this: The client starts by requesting a URL. The server looks at the requests, thinks "hey, I need to know who you are", and replies with HTTP status code 401 and the header "WWW-Authenticate" set to
Basic realm="My app"
The realm name can be whatever you want. A back-end can expose several different realms, in case different parts of the application requires different credentials.
The client receives this 401 response, see that it knows the authentication scheme set in the header ("Basic"), and in the case of a browser it will show a default log-in dialog to the user. The client will then retry the request with the "Authorization" header set to something like:
Basic dXNlcjpwYXNzd29yZAo=
where the gibberish part is the Base64-encoded user name and password separated by a colon - the above example is "user:password".
The server will decode the Base64 string and check the credentials. If the user name and password are correct, the real response will be returned. If not, the back-end will send another 401 response.
Since Base64 is just an encoding, not an encryption scheme, HTTP Basic Authentication in practice sends the user name and password in cleartext over the wire. Therefore, you really shouldn't use it for anything sensitive unless you're running HTTPS - just like any other log-in method where you send the user name and password directly to the server at any point.
It's a really nice and simple authentication scheme, and since all command-line utilities and all HTTP libraries out there support HTTP Basic Authentication in some way, it's perfect for REST services.
My initial plan
I had read a blog about handling authentication in AngularJS using promises. It's a great read, and it's a really beautiful use of promises: Register an HTTP interceptor which "catches" 401 errors, put these requests aside and let the application show a log-in dialog. Once the log-in dialog is completed, retry the requests with the new credentials. Since we're using promises, the code sending the original HTTP requests will never know or care about what's happening, even if the user takes hours filling in the log-in dialog.
So my plan was:
- Secure only the REST services in the back-end, not the JavaScript code.
- Plug in the aforementioned HTTP Authentication interceptor and handle the notifications sent by this by showing a nice Twitter Bootstrap log-in dialog.
- Store the credentials somewhere in the client.
- Implement a "log out" button which deletes these credentials.
Securing just the REST services
This was really easy: I created a Sinatra controller called SecuredController which secured controllers would inherit from. This would use Rack::Auth::Basic, like this:
use Rack::Auth::Basic, 'SimplePVR' do |username, password|
username == real_username && password == real_password
end
The other controllers (those serving the Angular application) would be accessible without authentication.
Plugging in the HTTP Authentication interceptor
This was also quite easy. And I created an Angular directive which would simply show or hide a Twitter Bootstrap modal dialog (oh, so pretty!) according to the events sent by the interceptor:
directive('loginDialog', function() {
return {
templateUrl: '/app/templates/loginDialog.html',
restrict: 'E',
replace: true,
controller: CredentialsController,
link: function(scope, element, attributes, controller) {
scope.$on('event:auth-loginRequired', function() {
element.modal('show');
});
scope.$on('event:auth-loginConfirmed', function() {
element.modal('hide');
scope.credentials.password = '';
});
}
}
});
The form in the loginDialog.html template would, on submit, call a logIn function from the CredentialsController which would set the correct HTTP Basic Authentication header:
var encodedUserNameAndPassword = encodeBase64(userName + ':' + password);
$http.defaults.headers.common['Authorization'] = 'Basic ' + encodedUserNameAndPassword;
I found a nice Base64 encoding function here - by the way, that is one of the many blogs giving an incomplete solution to this whole very problem.
Storing the credentials
I'm not super proud of it, but I ended up storing the credentials in a cookie. I just stored the "encodedUserNameAndPassword" from above:
$cookieStore.put('basicCredentials', encodedUserNameAndPassword);
I did this because it was easier than checking whether the browser supports localStorage, and then storing it there - Angular comes with built-in Cookie support.
But if you think about it, this does not weaken the security of the system, since encodedUserNameAndPassword is sent in the Authorization header anyway.
One possible improvement would be to make the cookie secure when the back-end is running HTTPS, but I don't see an easy way to do this with Angular. I would definitely have done this for a more critical application.
One possible improvement would be to make the cookie secure when the back-end is running HTTPS, but I don't see an easy way to do this with Angular. I would definitely have done this for a more critical application.
That was supposed to be it
...but it didn't work. Before the HTTP Authentication interceptor could do anything with the HTTP 401 status code, the user was presented with this dreaded dialog (this is the Firefox version, but other versions are not prettier):
Surfing the web, there seems to be a lot of confusion about how to avoid this when accessing resources secured by HTTP Basic Authentication, but the conclusion is that it's not possible! The browser shows this dialog because it knows HTTP Basic Authentication, and only when the user enters invalid credentials will the calling JavaScript receive the response with status code 401.
So my pretty Twitter Bootstrap dialog would be shown only after the user had entered invalid credentials in the browser's native log-in dialog.
Damnation!
A work-around
Further surfing the web, I found somebody proposing simply changing the authentication method presented by the server when the request has the header "X-Requested-With: XMLHttpRequest", which e.g. JQuery automatically adds to all Ajax requests.
So instead of simply using Rack::Auth::Basic, I had to implement my own authentication in Sinatra, which acts as a normal HTTP Basic Authentication when not called by Ajax, and which presents something else than "Basic" for Ajax requests. I chose "xBasic", but as long as it's something that the browser does not recognise, all is fine.
Angular actually does not set the X-Requested-With header like JQuery does, so I had to insert this configuration line:
$httpProvider.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
It worked!
The REST calls from the Angular application would now actually fail without any native log-in dialog and let our application handle the error. My nice Twitter Bootstrap modal log-in dialog was shown, and the user could enter the credentials. After that, all requests would get the Authentication header set with the entered credentials.
All seemed well, but then I tried first entering wrong credentials, which would make the request retries fail, and then entering the correct credentials. Something weird happened: The new request retries still had the old credentials set in the Authorization header.
I scratched my head quite a lot - why didn't Angular see that I had changed the default headers with this line I showed you previously??
$http.defaults.headers.common['Authorization'] = 'Basic ' + encodedUserNameAndPassword;
Then it dawned on me: I had previously set the default value of the Authorization header with the wrong credentials, and all requests from then on would have this header set. When I was changing the default and the HTTP Authentication interceptor was retrying "old requests", these "old requests" had already got an Authorization header, and so Angular didn't care about the new default value.
So in the HTTP Authentication interceptor, right before the request retry, I had to insert this line of code to make room for the new default value:
delete config.headers['Authorization'];
It worked!
Angular actually does not set the X-Requested-With header like JQuery does, so I had to insert this configuration line:
$httpProvider.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
It worked!
The REST calls from the Angular application would now actually fail without any native log-in dialog and let our application handle the error. My nice Twitter Bootstrap modal log-in dialog was shown, and the user could enter the credentials. After that, all requests would get the Authentication header set with the entered credentials.
All seemed well, but then I tried first entering wrong credentials, which would make the request retries fail, and then entering the correct credentials. Something weird happened: The new request retries still had the old credentials set in the Authorization header.
I scratched my head quite a lot - why didn't Angular see that I had changed the default headers with this line I showed you previously??
$http.defaults.headers.common['Authorization'] = 'Basic ' + encodedUserNameAndPassword;
Then it dawned on me: I had previously set the default value of the Authorization header with the wrong credentials, and all requests from then on would have this header set. When I was changing the default and the HTTP Authentication interceptor was retrying "old requests", these "old requests" had already got an Authorization header, and so Angular didn't care about the new default value.
So in the HTTP Authentication interceptor, right before the request retry, I had to insert this line of code to make room for the new default value:
delete config.headers['Authorization'];
You can see the altered interceptor service here.
It's a pity that this requires changes in the server, as it means that you cannot just put an Angular application in front of an existing REST service without changing it.
But I was happy and thought I had got everything right. The Angular application was accessing the REST back-end perfectly, I had a log-out button, I had my pretty Twitter Bootstrap log-in dialog, the REST services were still directly accessible with HTTP Basic Authentication (which means that command-line tools and my XBMC plug-in worked with no changes).
But I was happy and thought I had got everything right. The Angular application was accessing the REST back-end perfectly, I had a log-out button, I had my pretty Twitter Bootstrap log-in dialog, the REST services were still directly accessible with HTTP Basic Authentication (which means that command-line tools and my XBMC plug-in worked with no changes).
But... just one more thing...
Sometimes the REST service would serve an image URL, and the URL would be put in a normal image tag. Now the browser was fetching the image from the secured REST service... and this guy was back:
I was in despair for some time - should I choose to expose the images without security, or should I ditch HTTP Basic Authentication altogether?
It occurred to me that I could implement something of a hack in my server-side authentication code: If the request does not specify HTTP Basic Authentication credentials in the standard header, it will look in the aforementioned cookie.
This further adds to what has to be changed in your REST service if you are planning "just" adding an Angular front-end, but all things considered it's still a very simple authentication scheme.
As for the back-end authentication code, here's the test, and here's the implementation.
Fixing a GUI lock-up
A final problem was the modal dialog: Hiding and showing it too quickly didn't correctly clean up the backdrop, resulting in a darker and darker, inaccessible page behind the modal dialog, even when the user eventually entered the right credentials. I ended up delaying the "show" part. Have a look at the code if you care.