Most non-trivial apps need to deal with user authentication. In this post, I'll walk through how to implement user auth in an AngularJS application and how to account for some complex scenarios that might arise.
###Step 0 - Create auth file
I find it useful to put all this code in one place, so I've created a file called auth.js.coffee in my /angular/services directory.
auth = angular.module "genericapp.auth"
I'll be using genericapp
in place of, what is hopefully, a better name for your app.
###Step 1 - Handling Tokens
Whether you're using cookies of session storage to store your auth tokens, its useful to create a Token service to manage getting and setting the token. In addition to cleaning up other parts of your app that access this code and keeping it DRY, it will allow you to easily change how you're storing tokens quickly and easily should you need to do so.
Here is the Token service implemented using angular-cookies
auth = angular.module "genericapp.auth", ["ngCookies"]
auth.factory "Token", ["$cookies", ($cookies) ->
emitter = angular.element({})
clear: -> delete $cookies.genericapp_session_token
get: -> $cookies.genericapp_session_token
set: (token) ->
$cookies.genericapp_session_token = token
emitter.triggerHandler 'set'
]
###Step 2 - Creating and deleting sessions
Auth.create_session
will be called when the user logs in and Auth.destroy_session
will be called when the user logs out. We're broadcasting the events from the rootScope so that any controller that needs to take an action when the user logs in or logs out can listen for those events and handle them appropriately. It is also useful to note that the location hash is not cleared automatically and if you don't want it to persist, you must clear it manually like we are doing above.
auth.factory "Auth", ["$location", "$rootScope", "Token", ($location, $rootScope, Token) ->
create_session: (user, token) ->
Token.set token
$rootScope.$broadcast("event:login", user)
destroy_session: ->
Token.clear()
$location.hash("")
$rootScope.$broadcast("event:logout")
]
###Step 3 - Handling server 401s
On the client side, our authentication handling is pretty basic: we're just checking to see if a token exists. This is sufficient because any request to the server includes the authentication token and performs a thorough check of its validity. If our token has expired or been deleted from the server, any request made will return a 401 - unauthorized
and this should prompt the user to login in again.
To handle this in out auth.js.coffee file we'll broadcast event:auth-required
:
auth.config ["$httpProvider", ($httpProvider) ->
$httpProvider.interceptors.push ["$q", "$rootScope", ($q, $rootScope) ->
responseError: (rejection) ->
if rejection.status == 401
$rootScope.$broadcast("event:auth-required")
$q.reject(rejection)
]
]
Set up a listener for that event:
auth.run ["$rootScope", "Auth", ($rootScope, Auth) ->
$rootScope.$on "event:auth-required", Auth.on_401
]
And run the new Auth.on_401 method to clear the old token and redirect the user to our login page:
auth.factory "Auth", ["$location", "$rootScope", "Token", ($location, $rootScope, Token) ->
sign_in = -> $location.path "/sign_in"
on_401: ->
Token.clear()
sign_in()
create_session: (user, token) ->
Token.set token
$rootScope.$broadcast("event:login", user)
destroy_session: ->
Token.clear()
$location.hash("")
$rootScope.$broadcast("event:logout")
]
###Step 4 - Authenticate on location change
auth.factory "Auth", ["$location", "$rootScope", "Token", ($location, $rootScope, Token) ->
sign_in = -> $location.path "/sign_in"
on_location_change: (event, requested_location, current_location) ->
if !Token.get()
$rootScope.final_destination = requested_location
sign_in()
on_401: ->
Token.clear()
sign_in()
create_session: (user, token) ->
Token.set token
$rootScope.$broadcast("event:login", user)
destroy_session: ->
Token.clear()
$location.hash("")
$rootScope.$broadcast("event:logout")
]
auth.run ["$rootScope", "Auth", ($rootScope, Auth) ->
$rootScope.$on "$locationChangeStart", Auth.on_location_change
$rootScope.$on "event:auth-required", Auth.on_401
]
Angular broadcasts $locationChangeStart
before a URL will change, allowing us to listen for this event and redirect a user to our sign in page if they do not have a valid token.
This is everything you need for basic authentication in AngularJS.
There are a couple of additions that we made to accomodate some less standard functionality in the app that I'm currently working on. Here are some additional steps you might need to take if you app requires more than the standard functionality.
###Step 5 - Adding public pages to your Angular app
The easiest way to handle public landing pages is to keep them outside of your angular app. This is a great solution for marketing pages, frequently asked questions or a blog where the content and structure is unrelated to your angular app. But having public pages inside your angular app also has the advantages of sharing functionality and styles more easily.
In our app, the landing page pulls a set of profile picture to display as well as some testimonial content that is used inside the app. While both of those thing could also be pulled from our server in a different application, its helpful to use the same directives and styling to render them.
We also need a way to mark our sign in page as public. This could easily be done as a special case in auth.js.coffee
, but providing a general solution for public pages gives us a lot more flexibility.
Here are two examples of pages with public access for our app:
in main.js.coffee
$routeProvider
.
when "/",
templateUrl: "../templates/landings/show.html"
controller: "LoginCtrl"
access: "public"
resolve: ["$location", "$q", "Token", ($location, $q, Token) ->
if Token.get()
deferred = $q.defer()
deferred.reject()
$location.path "/home"
return deferred.promise
.
when "/sign_in",
templateUrl: "../templates/sessions/new.html"
controller: "LoginCtrl"
access: "public"
navigationMode: "off"
]
Now that we've flagged some pages as public in our route provider, we need to skip the authentication check for these pages.
auth.factory "Auth", ["$location", "$rootScope", "$route", "Token", ($location, $rootScope, $route, Token) ->
sign_in = -> $location.path "/sign_in"
on_route_change: (event, attempted_route, origin_route) ->
if !Token.get() && attempted_route.access != "public"
$rootScope.finalDestination = attempted_route.$$route.originalPath
sign_in()
on_401: ->
unless $route.current.$$route.access == "public"
Token.clear()
sign_in()
create_session: (user, token) ->
Token.set token
$rootScope.$broadcast("event:login", user)
destroy_session: ->
Token.clear()
$location.hash("")
$rootScope.$broadcast("event:logout")
]
auth.run ["$rootScope", "Auth", ($rootScope, Auth) ->
$rootScope.$on "$routeChangeStart", Auth.on_route_change
$rootScope.$on "event:auth-required", Auth.on_401
]
Since we need to access data on the route itself, the $locationChangeStart event and the basic, string urls that it provides aren't going to cut it anymore. Luckily, Angular also broadcasts $routeChangeStart when the route is changing. $routeChangeStart provides us with the current and attempted routes and with these, we can access that information that access flag that we set on our routes.
Note that in addition to ignoring authentication for our angular routing, we are also ignoring any 401s that we get from the server.
This is great and allows us to easily specify when authentication is required in our routes file. There is one catch however. While this works as expected for most routes like /#/users/new
or /#/top_secret_documents
if you try to navigate to /#/top_secret_documents/1
you'll find that attempted_route.$$route.originalPath
gives you /#/top_secret_documents/:id
instead. That's strange. If you fire up your debugger and take a look at attempted_route.$$route
you'll find a your id in attempted_route.params
.
Unfortunately nothing in attempted_route
actually holds the URL that you want to redirect to. Which brings us to...
###Step 6 - Fix routing path for $routeChangeStart
on_route_change: (event, attempted_route, origin_route) ->
if !Token.get() && attempted_route.access != "public"
final_destination = attempted_route.$$route.originalPath
for k,v of attempted_route.params
final_destination = final_destination.split(":" + k).join(v)
$rootScope.finalDestination = (final_destination || null)
sign_in()
In practice most params
will contain only :id
, but since lots of keys things are possible, we want to handle all of the cases.
Any strings for event names (such as
"event:login"
) may be better as pseudoconstants exposed via their relevant service.