Skip to content

Instantly share code, notes, and snippets.

@DanLuchi
Last active August 29, 2015 14:06
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save DanLuchi/1091b2ba734ac078ddda to your computer and use it in GitHub Desktop.
Save DanLuchi/1091b2ba734ac078ddda to your computer and use it in GitHub Desktop.
Redirecting a user to login in AngularJS

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.

@thegreatape
Copy link

Requiring that a user be authenticated to view particular parts of an application is very common. In this post, I'll walk through how to implement this functionality in an AngularJS application and how to account for some complex scenarios that might arise.

First sentence is a little weak. Maybe instead: "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 1's example still has "quitnet_" in the delete method.

Typo in Step 2: "Auth.destory_session".

Consider putting the explanations before the code example? Easier to parse if I know what to expect.

Step 3's description is hard to read:

Here we've added the on_401 method to our Auth service, an interceptor to $httpProvider to broadcast event:auth-required when any request returns a 401 and a listener on the rootScope that will run our Auth.on_401 method whenever a request returns a 401.
Instead, maybe: "Now when our auth request gets denied with a 401, we'll broadcast the event:auth-required event and run the new Auth.on_401 method.

The easiest way to handle public landing pages is to keep them outside of your angular app, but there are a lot of reasons that you might want public pages within your app.
Such as?

Step 5:

we can access that information that access flag that we set on our routes.
Unsure what this is trying to say.

Note that in addition to ignoring authentication for our angular routing, we are also ignoring any 401s that we get from the server.
Why should we be ignoring 401s? Just Don't make the reader think about that, just quickly explain.

Typo: "Thats strange."

@taylorkearns
Copy link

Camelcase all the things?

@DanLuchi
Copy link
Author

DanLuchi commented Sep 9, 2014

@thegreatape Great feedback. I fixed the typos, moved a lot of the descriptions above the code samples, broke down step 3 into smaller, more manageable chunks and added some more information on the pros and cons of public pages in your app.

@DanLuchi
Copy link
Author

DanLuchi commented Sep 9, 2014

@taylorkearns, not sure if serious...

@joelind
Copy link

joelind commented Sep 9, 2014

Any strings for event names (such as "event:login") may be better as pseudoconstants exposed via their relevant service.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment