I have done my best to conform to the specifications listed above and then some, but I have included some notes on my reservations, especially about the lack of exposing the URL routing scheme.
The way I have crafted this solution is to segregate the data service (ASP.NET Web API 2) from the data view (HTML5 + AngularJS). For the data service, I am using Visual Studio Express 2013 for Web with NuGet to provide scaffolding, package dependency resolution, and build tools. For the data view, I am using Yeoman (providing Grunt and Bower) to provide scaffolding, package dependency resolution, and build tools. This in no way implies that this is the way to do it; it just happens to be the way that I do it.
Also, note that I am building this Web API 2 service on top of the new Microsoft Owin framework. I could have built it directly on top of ASP.NET, but the internal OAuth 2.0 bearer token authentication flow for the ASP.NET Identity system is built on top of Owin, and I would rather implement everything in the same primary pipeline. I am adding CORS support to the application so that I can host my data service at api.example.com and my data data view atwww.example.com. CORS support should be set up specific to your applications needs. Globally supporting any incoming domain is a bad idea unless your specifications state that anyone should be able to consume the API.
On the web view side, I will not be using the normal $ngRoute service provided by AngularJS. The specifications of this example require it to only have a single route ("/"). This requires all the "routing" to be done through a state machine which is provided by AngularUI Router.
In my case, I've named it "SPA Authentication Example." I'm using Visual Studio Express for Web 2013, and I've specifically selected "Empty Project." If I selected "Web API" or "Single Page Application," the project template is going to cram MVC dependencies down my throat and KnockoutJS on top of that if I choose the SPA template. I really don't want either of those for this project, as noted above. The downside is that it will not give you authentication options. I'll manually add those later. So, now we have a basic project.
These are all the changes made in this step.
For me, this was just changing the default namespace to something more palatable and then performing a refactor operation on the codebase. I also enabled NuGet Package Restore so that the dependencies aren't included wholesale in my source code tree. These are personal preferences, so season to taste.
These are all the changes made in this step.
We will use NuGet to manage dependencies, so install the following using the package manager console:
Install-Package Microsoft.AspNet.Identity.Owin
Install-Package Microsoft.AspNet.Identity.EntityFramework
Install-Package Microsoft.Owin.Cors
Install-Package Microsoft.Owin.Host.SystemWeb
Install-Package Microsoft.AspNet.WebApi.Owin
I chose these packages specifically for their depedency chain cascade. After installing the above packages, you'll have everything you'll need for this project. I also took the chance to pull in the latest updates for dependencies.
We don't actually need a Global.asax
(or Global.asax.cs
for that matter) because everything is going to be passed down to the OWIN pipeline.
Create a new file Startup.cs at the root of your project. Replace the generated code with the following code:
using System.Web.Http;
using Owin;
using Microsoft.Owin;
using Microsoft.Owin.Cors;
[assembly: OwinStartup(typeof(Antaramian.SPAAuthenticationExample.Startup))]
namespace Antaramian.SPAAuthenticationExample
{
public partial class Startup
{
public void Configuration(IAppBuilder app)
{
HttpConfiguration config = new HttpConfiguration();
WebApiConfig.Register(config);
app.UseCors(CorsOptions.AllowAll);
app.UseWebApi(config);
}
}
}
We need to include the Owin
depdency in this file because that's what includes the IAppBuilder
interface. This interface is designed as a specification of the OWIN application middleware. The Microsoft.Owin
dependency provides the actual implementation of this interface. We also include Microsoft.Owin.Cors
to define our CORS policy which will be applied to any request. You'll see later how the authentication framework inherits this policy.
The assembly attribute directs Microsoft.Owin
as to which Startup class we want to use for our application. In this case, it's the class we are creating, Startup
. The class itself is partial because we will be defining more of it later in a file that deals specifically with authentication.
The only method that the Startup
class contains now is Configuration
which takes a parameter of the interface type IAppBuiler
, so any object implementing that interface can be passed in. This parameter will be supplied by the host at runtime.
We now add an HttpConfiguration
which we will really only be using for the purposes of route mapping. We pass the config object to WebApiConfig.Register()
to handle the routing. That code was defined by the project template in App_Start\WebApiConfig.cs, but it reads as follows for those who don't have it:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web.Http;
namespace Antaramian.SPAAuthenticationExample
{
public static class WebApiConfig
{
public static void Register(HttpConfiguration config)
{
config.MapHttpAttributeRoutes();
config.Routes.MapHttpRoute(
name: "DefaultApi",
routeTemplate: "api/{controller}/{id}",
defaults: new { id = RouteParameter.Optional }
);
}
}
}
The next line tells the app that we will be specifying a CORS policy. In this case, I am using the pre-defined AllowAll definition. This is only for testing purposes. In a production situation, you would typically create a new policy provider that determines the specific incoming HTTP origins, headers, and methods you expect.
Then we pass the config object to a method call on the app object called UseWebApi()
which is just telling the OWIN middleware to wire-up the ASP.NET Web API 2 framework to its pipeline.
We now have an app that works! It doesn't do anything, but it sure does compile.
Now, before adding the authentication code, we'll need something to authenticate against. In the Models
folder, create a new class called ExampleContext. Replace it with the following code:
using Microsoft.AspNet.Identity.EntityFramework;
namespace Antaramian.SPAAuthenticationExample.Models
{
public class ExampleContext : IdentityDbContext<IdentityUser>
{
public ExampleContext() : base("ExampleContext")
{
}
}
}
The key here is that our context is inheriting from the IdentityDbContext. We're also giving IdentityDbContext a TUser type of IdentityUser. IdentityUser is the basic user model included with ASP.NET Identity. You could create a new model that extends IdentityUser and add domain specific data to it. We don't need any of that here, though.
Now in your Web.config file, add something along the lines of the following to your <configuration> section:
<connectionStrings>
<add name="ExampleContext" connectionString="Data Source=(LocalDB)\v11.0;Initial Catalog=SPAAuthenticationExample;Integrated Security=SSPI;" providerName="System.Data.SqlClient" />
</connectionStrings>
And add lines of the following nature to the <entityFramework> section:
<contexts>
<context type="Antaramian.SPAAuthenticationExample.Models.ExampleContext, Antaramian.SPAAuthenticationExample" />
</contexts>
Your changes should look along the lines of this. Your actual entries will vary depending on your data source and namespace. Now in the package manager console, enter the following:
This will enable migrations on the project so that EntityFramework can migrate the database as the domain model evolves. It will also allow us to create seed data. In order to properly migrate the database, though, Entity Framework needs to make a snapshot of the data-model. Enter the following in the package manager console:
You can close the file it opens. It only shows the migration code. Instead, open the new Configuration.cs file in theMigrations
folder. This is where we can seed the database with data. It will run everytime the database is migrated upwards, so it's important that the code is aware of whether something exists in the database already or not. Replace what is currently there with:
namespace Antaramian.SPAAuthenticationExample.Migrations
{
using System.Data.Entity.Migrations;
using Antaramian.SPAAuthenticationExample.Models;
using Microsoft.AspNet.Identity;
using Microsoft.AspNet.Identity.EntityFramework;
internal sealed class Configuration : DbMigrationsConfiguration<ExampleContext>
{
public Configuration()
{
AutomaticMigrationsEnabled = false;
}
private bool AddUser(ExampleContext context)
{
IdentityResult identityResult;
UserManager<IdentityUser> userManager = new UserManager<IdentityUser>(new UserStore<IdentityUser>(context));
var user = new IdentityUser() {
UserName = "admin"
};
if(userManager.FindByName(user.UserName) != null) {
return true;
}
identityResult = userManager.Create(user, "password");
return identityResult.Succeeded;
}
protected override void Seed(ExampleContext context)
{
AddUser(context);
}
}
}
So that's a lot of code for very little results. It does include some administrative overhead though. First of all, the constructor is specifying that automatic migrations are off, so we have to explicitly create migrations via the package manager console before calling Update-Database
will actually make a change. I then define a private function that returns a bool. The return value isn't actually used for anything, but you could use it later if you, for example, need to make sure that the user set was created before seeding more data.
The AddUserAndRole()
function creates a new IdentityResult
object which will be used to monitor the outcome of Identity related actions. It then creates a UserManager<>
object passing in IdentityUser
as the TUser
type. As above in the ExampleContext
class, you would change this to be the name of whatever model class you use to extend IdentityUser
if you want to add more application-specific data to the model. In the constructor call for theUserManager
object, we create a new inline intance of the UserStore
object, again passing IdentityUser
as theTUser
type. The constructor for the UserStore
object takes the database access context as its argument, so we pass in the context which is in turn passed to AddUserAndRole()
from the Seed()
method.
Now we can create a new user. The IdentityUser
model is very sparse, so all we really get to define is the username. In this case we will user admin
. Next we check if the user already exists and return true if so. (We'll assume that the return value is a check on the existence of users and not on the ability to create them.) If the user doesn't exist, we will call the UserManager.Create()
method. The method takes the IdentityUser
(or derivative) object as well as a plaintext password and returns an IdentityResult
. Here we are just setting our user's password to password
. We take the IdentityResult
it returns and return the value of its Succeeded
parameter to the caller.
The Seed()
method will be called by the migration functions, so it's important that we put the call toAddUserAndRole()
in the function body in order for our custom function to actually execute.
After all that, in the package manager console execute:
If you followed the steps above, there should be no errors.
Now we're going to borrow from the SPA template that Microsoft provides with VS 2013. In it, there is a file that handles the provisioning of the OAuthAuthorizationServer
. You can just copy the code from there wholesale and put it in Providers\ApplicationOAuthProvider.cs. Make sure to change the namespace!
These are all the changes made in this step.
Now, from the ApplicationOAuthProvider class you just copied, remove the ClaimsIdentity cookieIdentity
line and the context.Request.Context.Authentication.SignIn(cookiesIdentity)
line. These lines are for cookie identity, and we'll only be using bearer authentication in our application.
Start by adding a file called Startup.Auth.cs to the App_Start
folder. Visual Studio will recognize the name pattern and the resuling class it generates is appropriately named Startup
. Replace that anyways with the following code:
using System;
using Microsoft.AspNet.Identity;
using Microsoft.AspNet.Identity.EntityFramework;
using Microsoft.Owin;
using Microsoft.Owin.Security.OAuth;
using Antaramian.SPAAuthenticationExample.Models;
using Antaramian.SPAAuthenticationExample.Providers;
using Owin;
namespace Antaramian.SPAAuthenticationExample
{
public partial class Startup
{
public static OAuthAuthorizationServerOptions OAuthOptions { get; private set; }
public static Func<UserManager<IdentityUser>> UserManagerFactory { get; set; }
static Startup()
{
String PublicClientId = "self";
UserManagerFactory = () => new UserManager<IdentityUser>(new UserStore<IdentityUser>(new ExampleContext()));
OAuthOptions = new OAuthAuthorizationServerOptions
{
TokenEndpointPath = new PathString("/token"),
Provider = new ApplicationOAuthProvider(PublicClientId, UserManagerFactory),
AccessTokenExpireTimeSpan = TimeSpan.FromHours(24),
AllowInsecureHttp = true
};
}
public void ConfigureAuth(IAppBuilder app)
{
app.UseOAuthBearerTokens(OAuthOptions);
}
}
}
As you can see, this is using the ApplicationOAuthProvider
from the last step. Here is where we are re-using thepartial
directive on the Startup class. This class definition will be merged with the class definition in Startup.cs
at the root-level of the project at compile-time. We are also creating two static parameters: OAuthOptions
, which will contain our application-specific settings for the ApplicationOAuthProvider
, and UserManagerFactory
which is a helper function for eating a new UserManager
session that is properly configured.
The static constructor will be run once per runtime environment of the application, so we handle some default set-up work in this function. First we set the string that the ApplicationOAuthProvider
will use to recognize authentications issued by itself. Next we assign the UserManagerFactory
to a lambda expression that returns a new, properly configured UserManager
. (Note: we are assigning a lambda function to a Func<TResult>
delegate, so the functionality will not actually occur until the delegate is called.) We then assign our application specific configuration options to the OAuthOptions parameter which will be passed to the authentication middleware later on.
The TokenEndpointPath
defines the location relative to the current application's root where you want the authentication middleware to respond. In this case, the authentication middelware will hanlde all requests to/token
. We then specify the actual provider that handles how a user should be checked for authentication; in this case, it's the ApplicationOAuthProvider
from the last step. We also set a timeframe for which tokens will be valid once issued. And we finally allow authentication over insecure HTTP transmissions for testing purposes.
The ConfigureAuth()
method will be used in the next step to actually add the authentication middleware to the pipeline. It takes the application builder object which we want to add the authentication middleware to and then adds the middleware by calling the UseOAuthBearerTokens()
method on the referenced object. It passes in the configuration settings we defined above.
OK, compile and run time!
You should be able to use an HTTP request tool (my vote is for Postman) to make a call to the API. What you'll need to do is make a POST
to http://localhost:{port}/token
with x-www-form-urlencoded
data of the form "grant_type=password&username=admin&password=password" (or enter these as key->value mappings depending on what your tool supports). For example:
Your response should look something like the following:
{
"access_token": "rC2ligrvhQqQDZlfYHbNvmcLJ2vhrU-R1t61F1WaZVE9ZGiI4QrcQghbdj-MkjeNQsNqEG15QQXUfFb2V7m2TkCDt0HNZANdBM-cY3GUsP7JJcZb_LXo4-3X16nccMUlxKF0ts-_4fgt-2nhfQ5vaEIZQq7dApIL1nRvl--eSSU5rXmR2Kk5cvFcLruOwyMc52CXb2qoYL9vkx4WTyQ8g-J6zxrBRtG37rv0d7xN1QngSt5y-a98vCzA4F1d-UA7TxOVMj7SJXElBlMkAzT-4t0rpOh-LIGKoPEJRwiAV0PSpXtJuVp6x3PptUo8j2rKg-tMAKPzG3M7tzxcMusgDaf5_cKFOQQnOcbdJK2AIK_IYwhX74sOdodIfHgEJDtBDMtZF_yH-Lz6dmVHiUWNV5v6ivTajKvISpazcG8kRps",
"token_type": "bearer",
"expires_in": 86399,
"userName": "admin",
".issued": "Sat, 15 Feb 2014 07:47:47 GMT",
".expires": "Sun, 16 Feb 2014 07:47:47 GMT"
}
Of course, we have nothing to actually view that requires authentication. But you are definitely authenticated!
These are all the changes made in this step.
To make things interesting, lets create some actual data that we will serve up through the API. We're going to create the following entities:
We'll also set them up to have some relationships between them and insert some default information. You can go ahead and copy in the Employee.cs, Region.cs, and Sale.cs files. The comments in them should be detailed enough to explain what their relation is to each other. Some relations, though, are described in the newExampleContext class using the fluent API. You'll also have to copy in the new Configuration class which defines some default data. The perform the following operations in the package manager console:
Add-Migration SalesData
Update-Database
OK, now we have to serve up that data. Add a new class to the Models
folder calledRegionSalesSummaryViewModel. This ViewModel provides the limited scope of data we are returning to the user
Also add a new Web API controller to the Controllers
folder called RegionsController. The controller defines a single endpoint, /regions/summary
, which returns the sales summary data for all regions only for authenticated users. The comments in the code should point you in the right direction as to what is happening, but the one major piece of code to point out is the smallest bit: [Authorize]
, which is an attribute that requires the user to be considered authenticated in order to proceed.
To demonstrate this, start the debugger. Try an HTTP GET request on http://localhost:{port}/regions/summary
. The server should respond with the HTTP status 401 Unauthorized, like in the following picture (emphasis bubble added):
To actually access the data, you should be able to use the access_token from step 9 (assuming you gave the access_token a reasonable expiration time). If you didn't save the token, you can just regenerate it by calling the HTTP POST method with the username and password on the token endpoint again. Add the "Authorization" header to the GET request with a value of "Bearer tokenData" where tokenData is replaced with the access token. It's important to keep a space between the word Bearer and the rest of the token. In Postman, this looks like the following:
You should now get an HTTP 200 OK response with a body like the following:
[
{
"Id": 1,
"Name": "North America",
"SalesDirector": "Sarah Doe",
"GrossSales": 10373.26,
"GrossSalesTarget": 9000
},
{
"Id": 2,
"Name": "Europe",
"SalesDirector": "John Q. Public",
"GrossSales": 4204.16,
"GrossSalesTarget": 6000
}
]
You can now authenticate into the server and retrieve data protected by [Authorize]
statements. You have also reached the end of the Web API construction section. It's time to move on to the construction of the data view.
I am building the data view layer using AngularJS, but I will be using Yeoman as a scaffolding tool. While it is entirely possible to build this solution by creating and linking the files by hand, I find that using Yeoman simply speeds up the process.
About AngularJS, AngularUI Router and the Lot of It
For the purposes of this example, I will be using AngularUI Router, and I will specifically be abusing it so that I do not have routes exposed at the URL level. In fact, I will not have routes at all because AngularUI works on a state-transition basis. It does have a built-in URL-to-state mapping system, but we will only be using that in a limited capacity because the specifications of the original question require only one root path to be exposed and transitions between states not to alter the URL. I would urge anyone considering this to think carefully about the specifications of their application and the implications of this methodology before implementing it. I, personally, do not recommend it.
So the first thing we will need to do is scaffold a new application. Because I'm using Yeoman (on a Mac no less), that basically looks like this (assuming you have the angular generator installed via npm install -g generate-angular
):
mkdir WebUI && cd WebUI
yo angular SPAAuthenticationExample
This will actually add some more overhead than we need because the Angular generator will provide unit-test scaffolding by default, even though I won't be making use of it for this example. You can browse the generated codebase to see what it provides.
Now we will need to install one of our major dependencies: AngularUI Router. The current version of AngularUI Router has some compatibility issues with the Bower packaging system, so you'll have to specifically install the specially tagged version if using Bower. Using Bower and Grunt:
bower install angular-ui-router#0.2.8-bowratic-tedium --save
grunt bower-install
These are all the changes made in this step.
Now we have to set-up our app to use AngularUI Router. First we need to change the directive in our primary div
from ng-view
to ui-view
(without assignment). Then we need to pull the header information out of main.html and into the index.html file. Then we can delete the useless About and Contact anchors (with their <li>
elements). On the Home link, change the ng-href
to ui-sref
. The ui-sref
directive is used by AngularUI to specify a state to navigate to. This state is defined in the $stateProvider
configuration which we will handle next. For now, assignui-sref
a value of "main."
In app.js
, delete the preexisting routing information. Your config function body should now be empty. Change the injected providers to $stateProvider
and $locationProvider
. $stateProvider
is the configuration engine for AngularUI Router's state machine. $locationProvider
is AngularJS's provider service for manipulating the URL.
Now that we have those dependencies, in our config function body we will first instruct AngularJS to set HTML5 mode to true for the URL:
$locationProvider.html5Mode(true);
This will remove the #
symbol from the URL. Then we will implement a state:
$stateProvider
.state('main',
{
url: '/',
templateUrl: 'views/main.html',
controller: 'MainCtrl',
});
Here we are instructing AngularUI Router to add a state main
to its state machine. AngularUI Router can assign states to a specific URL, in which case browsing to that URL causes the state machine to enter that state. In this case, we have set the URL to be the root of the application, which means that it is the first state a user will see. This is also the only state we will associate with a URL pursuant to our solution specifications. For this reason, no matter where a user navigates, the URL will not change, and there is no way to access a different state except through JavaScript manipulation.
States are also associated with templates and controllers, just like routes in the AngularJS router. Essentially, themain
state will show the user the main view with a scope of MainCtrl.
The next thing we will do is set-up a service that will provide us with our User authentication framework. We'll start by just creating a service where the user can't login. To scaffold a new service using Yeoman, run the following:
var userData = {
isAuthenticated: false,
username: '',
bearerToken: '',
expirationDate: null
};
this.getUserData = function() {
return userData;
};
Now whatever depends on the service can call User.getUserData()
and check the value of the isAuthenticated
parameter in the returned object to see whether the user is currently authenticated. To illustrate this, we will also create a new controller called HeaderCtrl that will manage the display of links in the header. With Yeoman:
yo angular:controller header
Pass $scope
and User
in as services that HeaderCtrl depends on, and then make the body of the controller the following:
$scope.user = User.getUserData();
When the HeaderCtrl is instanced, it will set a reference to the userData
object in User
as $scope.user
. This means that any template that inherits this scope can also call the properties of this object. Now in index.html, set the div element with a class of header to also have an ng-controller directive with a value of "HeaderCtrl" and add the following two navigation links below the Home anchor:
<li ng-hide="user.isAuthenticated"><a ui-sref="login">Login</a></li>
<li ng-show="user.isAuthenticated"><a ui-sref="logout">Logout</a></li>
We have our Login and Logout links! The login link will hide itself when the user is authenticated, and the logout link will show itself when the user is authenticated. You can use this to hide links to states that require authentication. The ng-show
and ng-hide
directives do have the added side-effect of causing a redraw after Angular finishes compiling the UI, though, so also add the ng-cloak
directive to the body so that the body only shows after all compiling has taken place.
Of course, right now the links won't work because those states haven't been defined.
Now, we have to actually write some logic about how to authenticate a user. In this step and the next step, our authentication will handle login and logout functions, but there will be no option to persist the login information across sessions. We will handle that later on.
The authentication service will be using the $http
service, so add it as a dependency in the User service.
We will start by adding two more functions to our User
service: this.authenticate()
and setHttpAuthHeader
.this.authenticate()
is the function we will expose to actually handle authentication while setHttpAuthHeader()
will be a simple helper function that sets the Authorization
header in the $http
service's default header set. Once this header is set, all uses of the $http
service after that will send the Authorization
header with the bearer token by default. This is defined as a separate function so that we can also call it from the authentication persistence layer later on.
The this.authenticate()
function takes four parameters: a username
, a password
, a successCallback
pointer and an errorCallback
pointer. The successCallback
pointer and errorCallback
pointer will be called based on the result of the HTTP transaction. The errorCallback
is expected to take a parameter that is a string which contains the error message.
In the this.authenticate()
function, we start by defining a config
object which will be handed to the $http
service. In the config
object, we set the method to POST
and assign the URL of the Token endpoint. We also set the Content-Type
header to application/x-www-form-urlencoded
. This is extremely important because the server will only process the data in this format. The data body is constructed by concatenating the parts of the data string to be sent to the server. Note that there is no encryption being done. On a development server that's fine, but the data can easily be sniffed from packets if the data is not transmitted over a secure connection.
The config
object is then handed to the $http
service and two promise functions are registered: success
andfailure
. If the HTTP transaction is successful, the success
function populates the userData
object with the returned data and sets the isAuthenticated
flag to true. setHttpAuthHeader()
then sets the default Authorization
header for the $http
service, and the successCallback
is called. If the HTTP transaction fails, the error function calls the errorCallback
function with an error message. The Web API will return an error_description
property for failed authentications, so if this property is not present on the data
object, we can assume the serve was never reached. In this, case we return a default error message asking the user to try again later.
In order to actually make this work, we have to have a controller that actually calls the authenticate method. I'm going to create a new controller called LoginCtrl
that will handle the actual login form and authentication. Using Yeoman:
yo angular:controller login
To LoginCtrl, I will add $scope
, $state
, and User
as dependencies. I will then define three variables at the scope level so I can interact with them in the view:
$scope.username
$scope.password
$scope.errors
$scope.username
and $scope.password
are initialized as empty strings while $scope.error
is initialized as an empty array. I will also add four helper functions to the controller:
disableLoginButton(message)
enableLoginButton(message)
onSuccessfulLogin()
onFailedLogin()
The disableLoginButton()
function disables the login button and replaces the button text with a message. By default the message is Attempting login...
. enableLoginButton()
does the opposite and has a default message ofSubmit
. onSuccessfulLogin()
defines what to do after the login is successful and is passed as the callback function to the User.authenticate()
method. Right now, it just sets the state to the main state. The onFailedLogin()
function defines what to to after the login fails. For now, it will push an error onto the $scope.errors
array, unless that error is already on the array, and then re-enable the login button.
The final function, login()
, is defined at the scope level so that it can be called by ng-submit
from a form. It disables the login button and then passes the necessary parameters to the User.authenticate()
method.
I'm also going to generate a corresponding view for the login control that acts as the login form:
yo angular:view login-form
The login-form view is just a form with an ng-submit
directive assigned to login()
. It also uses an ng-repeat
directive to display the errors. The username and password fields are bound to the scoped username
and password
variable through ng-model
.
To wire this up to our application, we need to add a state to the $stateProvider
in app.js like so:
.state('login',
{
templateurl: 'view/login-form.html',
controller: 'LoginCtrl'
});
Now, when you run the app, you should be able to login! You can tell because the app automatically sends you back to the main page. You should also be able to see the logout link, though it won't do anything yet. To de-authenticate, just reload the app by refreshing the page.
You can also try entering in fake info or shutting down the server to see how errors are presented.
These are all the changes made in this step.
Now that we're able to login, let's also make it possible to logout. I'm going to create a new Angular controller calledLogoutCtrl using Yeoman:
yo angular:controller logout
I'm not going to create a corresponding logout view, because I really don't feel there's a need for one. However, before I even implement the controller, I need to add some functionality to the User
service. In the source file for the User
service, I'm going to define a new function called clearUserData()
and a new method calledremoveAuthentication()
. clearUserData()
is responsible for re-initializing the userData
object in the User
service while removeAuthentication()
is responsible for calling clearUserData()
along with any other clean-up work when removing authentication including setting the $http
service Authorization
header to null
. In the future, it will be responsible for deleting persisted data as well.
Now in my LogoutCtrl
, I am going to specify that $state
and User
are LogoutCtrl
's dependencies. Then I am going to call User.removeAuthentication()
from the body and execute $state.go('main')
to send the user back to the home page.
Of course, it does nothing unless you wire it up in app.js:
.state('logout',
{
controller: 'LogoutCtrl'
});
Since we already defined the link in index.html
, everything should work once you reload the app.
Step 18: ui-sref
and CSS
As a quick step, note that with some CSS frameworks you will have to indicate that ui-sref
s are links too! The Bootstrap framework sets all links to have a pointer cursor, so I added this to my main.scss
file for consistency:
[ui-sref] {
cursor: pointer;
}
OK, let's actually use the $http
service (via the $resource
service, actually) to call our protected API endpoint. Because this task is so simple, I'm not going to bother to create a separate service for fetching the data; I will put it all right into the controller. I'll start a new controller as such:
yo angular:controller salesData
I will declare the dependencies of my SalesDataCtrl as $scope
and $resource
, and then I will set the body of the function as the following line:
$scope.salesSummaryByRegion = $resource('http://192.168.1.44:42042/regions/summary').query();
And that's it for that file. Now I need to create a new view for it:
yo angular:view sales-data
For the view, I have a table with an ng-repeat
on the first row of the table body. Then I just need to hook in the controller and the view to a state on in app.js:
.state('sales' {
templateUrl: 'views/sales-data.html',
controller: 'SalesDataCtrl'
})
<li><a ui-sref='sales'>Sales Data</a></li>
I am pointedly not adding a ng-show
or ng-hide
directive here so that we can see what happens when an unauthenticated user tries to browse to the page. We will handle redirection for authentication in the next step.
As you can see by running the app, if you try to browse to the page as an unauthenticated user, you'll just see the empty table. There won't be any error (unless you have your JS console open). If you login and then browse to the page, you can see the data just fine.
Let's assume for a second that you want to expose links to pages that require user authentication in order to see. In order to handle this, we will have to exploit the way that AngularUI Router handles exceptions. We will also have to make sure that once we redirect a user to the login form that the user is redirected back to the state they were trying to get to. This requires some fine tuned data management and volleying of information. We will use the User
service as repository for information when transitioning between states.
First we will define two new functions in the User service that act as exception constructors:NoAuthenticationException
and NextStateUndefinedException
. NoAuthenticationException
is thrown when theUser
service is asked whether the user is authenticated and it is unable to provide a valid (that is, not expired) token. NextStateUndefinedException
is thrown when the User
service is asked to provide the next state to transition to and there is no such state.
Next, we will define a repository for holding the data about the next state. This object, nextState
, holds a string called name
which is the name of the next state to navigate to and a string called error
which is the reason why the transition could not occur.
We also define the helper function isAuthenticationExpired()
which checks the expiration date set on the user data against the current time and this.isAuthenticated()
which checks the user authentication status.this.isAuthenticated()
depends on isAuthenticationExpired()
. this.isAuthenticated()
will throw aNoAuthenticationException
if it cannot find valid authentication data.
For state transitions, we define this.getNextState()
which returns the nextState object, unless the name
is set to an empty string, in which case it throws a NextStateUndefinedException
. The this.setNextState()
function takes two parameters and assigns them to the two parameters in the nextState
object. this.clearNextState()
reinitializes the nextState
object to empty details. It is the responsibility of whoever calls this.getNextState()
to also call this.clearStateData()
.
Now, in app.js, we are going to change the definition of the sales
state so that unauthenticated users cannot get access:
.state('sales', {
templateUrl: 'views/sales-data.html',
controller: 'SalesDataCtrl',
resolve: {
user: 'User',
authenticationRequired: function(user) {
user.isAuthenticated();
}
}
})
AngularUI Router will attempt to resolve the parameters before it transitions to the new state.user.isAuthenticated()
will throw an error if the user isn't authenticated and that will stop the state change. We will need to watch the error in the run component of our module:
.run(function($rootScope, $state, User) {
$rootScope.$on('$stateChangeError', function(event, toState, toParams, fromState, fromParams, error){
if (error.name === 'AuthenticationRequired') {
User.setNextState(toState.name, 'You must login to access this page');
$state.go('login', {}, {reload: true});
}
});
})
Essentially what we are doing here is registering a listener on the $stateChangeError
event. When it fires, it passes all the current data into the parameters defined in our anonymous function along with the error that caused it to occur. We know that the error that stops state changes because of user authentication will have a parameter with a name of AuthenticationRequired
. We then capture the target state information and pass it to User.setNextState()
with an error message of "You must login to access this page." We then tell AngularUI Router to transition to thelogin
state. We also set reload
to true
so that if someone is trying to access a protected page from the login page, the login page will display the error.
The first thing we do is set a local variable called nextState
which is initialized as null
. In the main body, we then have a try/catch statement which interacts with User.getNextState()
. It tries to get the next state that we should transition to, if any. If there is no next state, it ensures that nextState
is null
which indicates that this is a normal login request.
Next, we check whether the nextState
is not null
. If this evaluates to true, we have to do some fancy variable juggling to create copies of the original strings:
if(nextState !== null) {
var nameBuffer = nextState.name + '';
var errorBuffer = nextState.error + '';
User.clearNextState();
nextState = {
name: nameBuffer,
error: errorBuffer
};
// some other work
}
In a nutshell, because User.getNextState()
is returning an object by reference, when we callUser.clearStateData()
, it will just zero out the data we need to transition. We have to call clearStateData
at some point though, as a general housekeeping rule, otherwise the next time the user uses the authentication screen, they could be taken to an unexpected state.
Essentially we concatenate both strings with a blank string (which returns a new string object) and store them in buffer variables until after clearStateData is called. Then we recreate the nextState
object using those buffer variables. It then checks whether there is any error being passed in. If there is a string based error that is not empty and not already in the errors array, it pushes it onto the array. Otherwise, it assumes that, since there is nextState
data, someone tried to access a protected page and it pushes a default "You must be logged in to access this page" message.
onSuccessfulLogin()
must also be redefined. Instead of redirecting the user to the main state, it now checks whether a nextState is available, and if so, sends the user there instead of to the main state.
After making these changes, you should be able to click on the "Sales Data" link from the header bar and you will be presented with the login screen with errors displayed. If you login, you will be sent back to the "Sales Data" page where you can now see the data.
Last but not least, we need a way for a user to be able to save their authentication between browser sessions. This type of "remember me" functionality will be done using a cookie, and it doesn't require us to define any more services or controllers or views. First let's go into the User service and add the $cookieStore
dependency.
We will define two new exceptions: AuthenticationExpiredException
, which will be thrown when the system retrieves a cookie but the authentication has already expired and is therefore no longer valid, andAuthenticationRetrievalException
which will be thrown when there is no stored cookie data. We also define the functions saveData()
and removeData()
. saveData()
serializes the authData
object as a cookie whileremoveData()
deletes any serialized data.
The retrieveSavedData()
function is responsible for deserializing cookie data. If no such data exists, it will throw anAuthenticationRetrievalException
. If it successfully retrieves data, it checks to see if the data is still valid usingisAuthenticationExpired()
. If the data is not valid, it throws an AuthenticationExpiredException
. If the data is valid, it sets the userData
object to the deserialized data and sets the HTTP header appropriately usingsetHttpAuthHeader()
.
In order for this to work, though, we must call these functions from an exposed function. this.isAuthenticated()
works perfectly because its duty is to check whether a user is authenticated. Now it will also check for saved data. Like before, this.isAuthenticated()
will see if there is any current userData
and if it is expired. If the current data is valid, it will simply return true to indicate that the user is logged in. If not, it will attempt to retrieve any saved data using retrieveSavedData()
. If retrieveSavedData()
throws an error, this.isAuthenticated()
will throw an error as well. Otherwise, it will return true, indicating that the retrieval was successful and the user is now logged in. Note: This function never returns false, only true or an error. This is important for the way that we set up authentication re-routing in the previous step.
We also need to update the this.removeAuthentication()
function so that it removes stored auth cookies on logout by calling removeData()
.
Now, this.authenticate()
must be updated so that it knows whether to persist the authentication information. It will now take a persistData
parameter which it evaluates in the event of a successful HTTP transaction. If the parameter evaluates as true, the authentication data is serialized to cookie form so that it can be picked up by later instances of the app. Regardless, this.authenticate()
also calls removeAuthentication()
at the start of its execution since any call to authenticate indicates that the user is not currently authenticated and any old data should be removed.
In our LoginCtrl, we need to set a new scoped variable called $scope.persist
which defaults to false. We also insert it into our call of User.authenticate()
. We also need to define the variable as a checkbo checkbox on the login formthat is bound to the persist variable.
The last thing we need to do is change our module run in app.js to call User.isAuthenticated()
as such:
.run(function($rootScope, $state, User) {
try {
User.isAuthenticated();
} catch(e) {
//do nothing with this error
}
// $stateChangeError watch here...kept out for brevity
})
When you login now, you will have the option to persist your session! You can test this by logging in with the "Remember Me" checkbox activated, then closing the browser, and the opening the app back up. You should see the logout link in the heaer instead of the login link when the app starts. This means that the app is properly fetching the authentication data from the stored cookie!
This concludes the step-by-step example for authenticating a user via ASP.NET Web API in an AngularJS app using AngularUI Router without mapped routes for states.