Click here to Skip to main content
15,878,959 members
Articles / Web Development / ASP.NET

AngularJS Token Authentication using ASP.NET Web API 2, Owin, and Identity

Rate me:
Please Sign up or sign in to vote.
4.95/5 (43 votes)
3 Nov 2014CPOL10 min read 234.4K   93   32
AngularJS Token Authentication using ASP.NET Web API 2, Owin, and Identity

Introduction

This is the second part of AngularJS Token Authentication using ASP.NET Web API 2 and Owin middleware, you can find the first part using the link below:


You can check the demo application on (http://ngAuthenticationWeb.azurewebsites.net), play with the back-end API for learning purposes (http://ngauthenticationapi.azurewebsites.net), and check the source code on Github.

AngularJS Authentication

In this post, we’ll build sample SPA using AngularJS, this application will allow the users to do the following:

  • Register in our system by providing username and password.
  • Secure certain views from viewing by authenticated users (Anonymous users).
  • Allow registered users to log-in and keep them logged in for 24 hours or until they log-out from the system, this should be done using tokens.

If you are new to AngularJS, you can check my other tutorial which provides step by step instructions on how to build SPA using AngularJS, it is important to understand the fundamentals aspects of AngularJS before start working with it. In this tutorial, I’ll assume that readers have basic understanding of how AngularJS works.

Step 1: Download Third Party Libraries

To get started, we need to download all libraries needed in our application:

  • AngularJS: We’ll serve AngularJS from CDN, the version is 1.2.16
  • Loading Bar: We’ll use the loading bar as UI indication for every XHR request the application will made, to get this plugin we need to download it from here.
  • UI Bootstrap theme: To style our application, we need to download a free bootstrap ready made theme from http://bootswatch.com/ I’ve used a theme named “Yeti”.

Step 2: Organize Project Structure

You can use your favorite IDE to build the web application, the app is completely decoupled from the back-end API, there is no dependency on any server side technology here, in my case, I’m using Visual Studio 2013 so add new project named “AngularJSAuthentication.Web” to the solution we created in the previous post, the template for this project is “Empty” without any core dependencies checked.

After you add the project, you can organize your project structure as the image below, I prefer to contain all the AngularJS application and resources files we’ll create in folder named “app”.

AngularJS Project Structure

Step 3: Add the Shell Page (index.html)

Now we’ll add the “Single Page” which is a container for our application, it will contain the navigation menu and AngularJS directive for rendering different application views “pages”. After you add the “index.html” page to project root, we need to reference the 3rd party JavaScript and CSS files needed as the below:

HTML
<!DOCTYPE html>
<html data-ng-app="AngularAuthApp">
<head>
    <meta content="IE=edge, chrome=1" http-equiv="X-UA-Compatible" />
    <title>AngularJS Authentication</title>
    <link href="content/css/bootstrap.min.css" rel="stylesheet" />
    <link href="content/css/site.css" rel="stylesheet" />
<link href="content/css/loading-bar.css" rel="stylesheet" />
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
</head>
<body>
    <div class="navbar navbar-inverse navbar-fixed-top" 
    role="navigation" data-ng-controller="indexController">
        <div class="container">
            <div class="navbar-header">
                  <button class="btn btn-success navbar-toggle" 
                  data-ng-click="navbarExpanded = !navbarExpanded">
                        <span class="glyphicon glyphicon-chevron-down"></span>
                    </button>
                <a class="navbar-brand" href="#/">Home</a>
            </div>
            <div class="collapse navbar-collapse" data-collapse="!navbarExpanded">
                <ul class="nav navbar-nav navbar-right">
                    <li data-ng-hide="!authentication.isAuth">
                    <a href="#">Welcome {{authentication.userName}}</a></li>
                    <li data-ng-hide="!authentication.isAuth">
                    <a href="#/orders">My Orders</a></li>
                    <li data-ng-hide="!authentication.isAuth">
                    <a href="" data-ng-click="logOut()">Logout</a></li>
                    <li data-ng-hide="authentication.isAuth"> 
                    <a href="#/login">Login</a></li>
                    <li data-ng-hide="authentication.isAuth"> 
                    <a href="#/signup">Sign Up</a></li>
                </ul>
            </div>
        </div>
    </div>
    <div class="jumbotron">
        <div class="container">
            <div class="page-header text-center">
                <h1>AngularJS Authentication</h1>
            </div>
            <p>This single page application is built using AngularJS, 
            it is using OAuth bearer token authentication, ASP.NET Web API 2, 
            OWIN middleware, and ASP.NET Identity to generate tokens and register users.</p>
        </div>
    </div>
    <div class="container">
        <div data-ng-view="">
        </div>
    </div>
    <hr />
    <div id="footer">
        <div class="container">
            <div class="row">
                <div class="col-md-6">
                    <p class="text-muted">Created by Taiseer Joudeh. Twitter: 
                    <a target="_blank" 
                    href="http://twitter.com/tjoudeh">@tjoudeh</a></p>
                </div>
                <div class="col-md-6">
                    <p class="text-muted">Taiseer Joudeh Blog: 
                    <a target="_blank" 
                    href="http://bitoftech.net">bitoftech.net</a></p>
                </div>
            </div>
        </div>
    </div>
    <!-- 3rd party libraries -->
    <script src="//ajax.googleapis.com/ajax/libs/angularjs/1.2.16/angular.min.js"></script>
    <script src="//ajax.googleapis.com/ajax/libs/angularjs/1.2.16/angular-route.min.js"></script>
    <script src="scripts/angular-local-storage.min.js"></script>
    <script src="scripts/loading-bar.min.js"></script>
    <!-- Load app main script -->
    <script src="app/app.js"></script>
    <!-- Load services -->
    <script src="app/services/authInterceptorService.js"></script>
    <script src="app/services/authService.js"></script>
    <script src="app/services/ordersService.js"></script>
    <!-- Load controllers -->
    <script src="app/controllers/indexController.js"></script>
    <script src="app/controllers/homeController.js"></script>
    <script src="app/controllers/loginController.js"></script>
    <script src="app/controllers/signupController.js"></script>
    <script src="app/controllers/ordersController.js"></script>
</body>
</html>

Step 4: “Booting up” our Application and Configure Routes

We’ll add file named “app.js” in the root of folder “app”, this file is responsible to create modules in applications, in our case, we’ll have a single module called “AngularAuthApp”, we can consider the module as a collection of services, directives, filters which is used in the application. Each module has configuration block where it gets applied to the application during the bootstrap process.

As well we need to define and map the views with the controllers so open “app.js” file and paste the code below:

JavaScript
var app = angular.module('AngularAuthApp', 
['ngRoute', 'LocalStorageModule', 'angular-loading-bar']);

app.config(function ($routeProvider) {

    $routeProvider.when("/home", {
        controller: "homeController",
        templateUrl: "/app/views/home.html"
    });

    $routeProvider.when("/login", {
        controller: "loginController",
        templateUrl: "/app/views/login.html"
    });

    $routeProvider.when("/signup", {
        controller: "signupController",
        templateUrl: "/app/views/signup.html"
    });

    $routeProvider.when("/orders", {
        controller: "ordersController",
        templateUrl: "/app/views/orders.html"
    });

    $routeProvider.otherwise({ redirectTo: "/home" });
});

app.run(['authService', function (authService) {
    authService.fillAuthData();
}]);

So far, we’ve defined and mapped 4 views to their corresponding controllers as the below:

Orders View

Step 5: Add AngularJS Authentication Service (Factory)

This AngularJS service will be responsible for signing up new users, log-in/log-out registered users, and store the generated token in client local storage so this token can be sent with each request to access secure resources on the back-end API, the code for AuthService will be as below:

JavaScript
'use strict';
app.factory('authService', ['$http', '$q', 
'localStorageService', function ($http, $q, localStorageService) {

    var serviceBase = 'http://ngauthenticationapi.azurewebsites.net/';
    var authServiceFactory = {};

    var _authentication = {
        isAuth: false,
        userName : ""
    };

    var _saveRegistration = function (registration) {

        _logOut();

        return $http.post(serviceBase + 'api/account/register', registration).then(function (response) {
            return response;
        });
    };

    var _login = function (loginData) {

        var data = "grant_type=password&username=" + 
        loginData.userName + "&password=" + loginData.password;

        var deferred = $q.defer();

        $http.post(serviceBase + 'token', data, { headers: 
        { 'Content-Type': 'application/x-www-form-urlencoded' } }).success(function (response) {

            localStorageService.set('authorizationData', 
            { token: response.access_token, userName: loginData.userName });

            _authentication.isAuth = true;
            _authentication.userName = loginData.userName;

            deferred.resolve(response);

        }).error(function (err, status) {
            _logOut();
            deferred.reject(err);
        });

        return deferred.promise; 
    };

    var _logOut = function () {

        localStorageService.remove('authorizationData');

        _authentication.isAuth = false;
        _authentication.userName = ""; 
    };

    var _fillAuthData = function () {

        var authData = localStorageService.get('authorizationData');
        if (authData)
        {
            _authentication.isAuth = true;
            _authentication.userName = authData.userName;
        }  
    }

    authServiceFactory.saveRegistration = _saveRegistration;
    authServiceFactory.login = _login;
    authServiceFactory.logOut = _logOut;
    authServiceFactory.fillAuthData = _fillAuthData;
    authServiceFactory.authentication = _authentication;

    return authServiceFactory;
}]);

Now by looking on the method “_saveRegistration”, you will notice that we are issuing HTTP Post to the end point “http://ngauthenticationapi.azurewebsites.net/api/account/register” defined in the previous post, this method returns a promise which will be resolved in the controller.

The function “_login” is responsible to send HTTP Post request to the endpoint “http://ngauthenticationapi.azurewebsites.net/token”, this endpoint will validate the credentials passed and if they are valid it will return an “access_token”. We have to store this token into persistence medium on the client so for any subsequent requests for secured resources we’ve to read this token value and send it in the “Authorization” header with the HTTP request.

Notice that we have configured the POST request for this endpoint to use “application/x-www-form-urlencoded” as its Content-Type and sent the data as string not JSON object.

The best way to store this token is to use AngularJS module named “angular-local-storage” which gives access to the browsers local storage with cookie fallback if you are using old browser, so I will depend on this module to store the token and the logged in username in key named “authorizationData”. We will use this key in different places in our app to read the token value from it.

As well we’ll add object named “authentication” which will store two values (isAuth, and username). This object will be used to change the layout for our index page.

Step 6: Add the Signup Controller and its View

The view for the signup is simple so open file named “signup.html” and add it under folders “views” open the file and paste the HTML below:

HTML
<form class="form-login" role="form">
    <h2 class="form-login-heading">Sign up</h2>
    <input type="text" class="form-control" 
    placeholder="Username" data-ng-model="registration.userName" required autofocus>
    <input type="password" class="form-control" 
    placeholder="Password" data-ng-model="registration.password" required>
    <input type="password" class="form-control" 
    placeholder="Confirm Password" data-ng-model="registration.confirmPassword" required>
    <button class="btn btn-lg btn-info btn-block" 
    type="submit" data-ng-click="signUp()">Submit</button>
    <div data-ng-hide="message == ''" 
    data-ng-class="(savedSuccessfully) ? 'alert alert-success' : 'alert alert-danger'">
        {{message}}
    </div>
</form>

Now we need to add controller named “signupController.js” under folder “controllers”, this controller is simple and will contain the business logic needed to register new users and call the “saveRegistration” method we’ve created in “authService” service, so open the file and paste the code below:

JavaScript
'use strict';
app.controller('signupController', ['$scope', '$location', 
'$timeout', 'authService', function ($scope, $location, $timeout, authService) {

    $scope.savedSuccessfully = false;
    $scope.message = "";

    $scope.registration = {
        userName: "",
        password: "",
        confirmPassword: ""
    };

    $scope.signUp = function () {

        authService.saveRegistration($scope.registration).then(function (response) {

            $scope.savedSuccessfully = true;
            $scope.message = "User has been registered successfully, 
                you will be redicted to login page in 2 seconds.";
            startTimer();

        },
         function (response) {
             var errors = [];
             for (var key in response.data.modelState) {
                 for (var i = 0; i < response.data.modelState[key].length; i++) {
                     errors.push(response.data.modelState[key][i]);
                 }
             }
             $scope.message = "Failed to register user due to:" + errors.join(' ');
         });
    };

    var startTimer = function () {
        var timer = $timeout(function () {
            $timeout.cancel(timer);
            $location.path('/login');
        }, 2000);
    }

}]);

Step 6: Add the log-in Controller and its View

The view for the log-in is simple so open file named “login.html” and add it under folders “views” open the file and paste the HTML below:

HTML
<form class="form-login" role="form">
    <h2 class="form-login-heading">Login</h2>
    <input type="text" class="form-control" 
    placeholder="Username" data-ng-model="loginData.userName" required autofocus>
    <input type="password" class="form-control" 
    placeholder="Password" data-ng-model="loginData.password" required>
    <button class="btn btn-lg btn-info btn-block" 
    type="submit" data-ng-click="login()">Login</button>
     <div data-ng-hide="message == ''" class="alert alert-danger">
        {{message}}
    </div>
</form>

Now we need to add controller named “loginController.js” under folder “controllers”, this controller will be responsible to redirect authenticated users only to the orders view, if you tried to request the orders view as anonymous user, you will be redirected to log-in view. We’ll see in the next steps how we’ll implement the redirection for anonymous users to the log-in view once users request a secure view.

Now open the “loginController.js” file and paste the code below:

JavaScript
'use strict';
app.controller('loginController', ['$scope', '$location', 
'authService', function ($scope, $location, authService) {

    $scope.loginData = {
        userName: "",
        password: ""
    };

    $scope.message = "";

    $scope.login = function () {

        authService.login($scope.loginData).then(function (response) {

            $location.path('/orders');

        },
         function (err) {
             $scope.message = err.error_description;
         });
    };

}]);

Step 7: Add AngularJS Orders Service (Factory)

This service will be responsible to issue HTTP GET request to the end point “http://ngauthenticationapi.azurewebsites.net/api/orders” we’ve defined in the previous post, if you recall we added “Authorize” attribute to indicate that this method is secured and should be called by authenticated users, if you try to call the end point directly you will receive HTTP status code 401 Unauthorized.

So add new file named “ordersService.js” under folder “services” and paste the code below:

'use strict';
app.factory('ordersService', ['$http', function ($http) {

    var serviceBase = 'http://ngauthenticationapi.azurewebsites.net/';
    var ordersServiceFactory = {};

    var _getOrders = function () {

        return $http.get(serviceBase + 'api/orders').then(function (results) {
            return results;
        });
    };

    ordersServiceFactory.getOrders = _getOrders;

    return ordersServiceFactory;

}]);

By looking at the code above you’ll notice that we are not setting the “Authorization” header and passing the bearer token we stored in the local storage earlier in this service, so we’ll receive 401 response always! Also we are not checking if the response is rejected with status code 401 so we redirect the user to the log-in page.

There is nothing prevent us from reading the stored token from the local storage and checking if the response is rejected inside this service, but what if we have another services that needs to pass the bearer token along with each request? We’ll end up replicating this code for each service.

To solve this issue, we need to find a centralized place so we add this code once so all other services interested in sending bearer token can benefit from it, to do so we need to use “AngularJS Interceptor“.

Step 8: Add AngularJS Interceptor (Factory)

Interceptor is regular service (factory) which allow us to capture every XHR request and manipulate it before sending it to the back-end API or after receiving the response from the API. In our case, we are interested to capture each request before sending it so we can set the bearer token, as well we are interested in checking if the response from back-end API contains errors which means we need to check the error code returned so if its 401 then we redirect the user to the log-in page.

To do so, add new file named “authInterceptorService.js” under “services” folder and paste the code below:

JavaScript
'use strict';
app.factory('authInterceptorService', ['$q', '$location', 
'localStorageService', function ($q, $location, localStorageService) {

    var authInterceptorServiceFactory = {};

    var _request = function (config) {

        config.headers = config.headers || {};

        var authData = localStorageService.get('authorizationData');
        if (authData) {
            config.headers.Authorization = 'Bearer ' + authData.token;
        }

        return config;
    }

    var _responseError = function (rejection) {
        if (rejection.status === 401) {
            $location.path('/login');
        }
        return $q.reject(rejection);
    }

    authInterceptorServiceFactory.request = _request;
    authInterceptorServiceFactory.responseError = _responseError;

    return authInterceptorServiceFactory;
}]);

By looking at the code above, the method “_request” will be fired before $http sends the request to the back-end API, so this is the right place to read the token from local storage and set it into “Authorization” header with each request. Note that I’m checking if the local storage object is nothing so in this case this means the user is anonymous and there is no need to set the token with each XHR request.

Now the method “_responseError” will be hit after we receive a response from the back-end API and only if there is failure status returned. So we need to check the status code, in case it was 401 we’ll redirect the user to the log-in page where he’ll be able to authenticate again.

Now we need to push this inspector to the inspectors array, so open file "app.js" and add the below code snippet:

JavaScript
app.config(function ($httpProvider) {
    $httpProvider.interceptors.push('authInterceptorService');
});

By doing this, there is no need to setup extra code for setting up tokens or checking the status code, any AngularJS service executes XHR requests will use this interceptor. Note: This will work if you are using AngularJS service $http or $resource.

Step 9: Add the Index Controller

Now we’ll add the Index controller which will be responsible to change the layout for home page, i.e. (Display Welcome {Logged In Username}, Show My Orders Tab), as well we’ll add log-out functionality on it as the image below.

Index Bar

Taking in consideration that there is no straight way to log-out the user when we use token based approach, the work around we can do here is to remove the local storage key “authorizationData” and set some variables to their initial state.

So add a file named “indexController.js” under folder “controllers” and paste the code below:

JavaScript
'use strict';
app.controller('indexController', ['$scope', '$location', 'authService', function ($scope, $location, authService) {

    $scope.logOut = function () {
        authService.logOut();
        $location.path('/home');
    }

    $scope.authentication = authService.authentication;

}]);

Step 10: Add the Home Controller and its View

This is last controller and view we’ll add to complete the app, it is simple view and empty controller which is used to display two boxes for log-in and signup as the image below:

Home View

So add new file named “homeController.js” under the “controllers” folder and paste the code below:

JavaScript
'use strict';
app.controller('homeController', ['$scope', function ($scope) {
   
}]);

As well add new file named “home.html” under “views” folder and paste the code below:

HTML
<div class="row">
        <div class="col-md-2">
            &nbsp;
        </div>
        <div class="col-md-4">
            <h2>Login</h2>
            <p class="text-primary">If you have Username and Password, 
            you can use the button below to access the secured content using a token.</p>
            <p><a class="btn btn-info" href="#/login" 
            role="button">Login &raquo;</a></p>
        </div>
        <div class="col-md-4">
            <h2>Sign Up</h2>
            <p class="text-primary">Use the button below to create 
            Username and Password to access the secured content using a token.</p>
            <p><a class="btn btn-info" href="#/signup" 
            role="button">Sign Up &raquo;</a></p>
        </div>
        <div class="col-md-2">
            &nbsp;
        </div>
    </div>

By now, we should have SPA which uses the token based approach to authenticate users.

One side note before closing: The redirection for anonymous users to log-in page is done on client side code; so any malicious user can tamper with this. It is very important to secure all back-end APIs as we implemented on this tutorial and not to depend on client side code only.

That’s it for now! Hopefully these two posts will be beneficial for folks looking to use token based authentication along with ASP.NET Web API 2 and Owin middleware.

I would like to hear your feedback and comments if there is a better way to implement this especially redirection users to log-in page when the are anonymous.

You can check the demo application on (http://ngAuthenticationWeb.azurewebsites.net), play with the back-end API for learning purposes (http://ngauthenticationapi.azurewebsites.net), and check the source code on Github.

Follow me on Twitter @tjoudeh

The post AngularJS Token Authentication using ASP.NET Web API 2, Owin, and Identity appeared first on Bit of Technology.

This article was originally posted at http://bitoftech.net?p=509

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Architect App Dev Consultant
Jordan Jordan
Working for Microsoft Consultation Services as a Lead App Dev Consultant.

I have more than 15 years of experience in developing and managing different software solutions for the finance, transportation, logistics, and e-commerce sectors. I’ve been deeply involved in .NET development since early framework versions and currently, I work on different technologies on the ASP.NET stack with a deep passion for Web API, Distributed systems, Microservices, and Microsoft Azure.

Prior to joining Microsoft, I have been awarded the Microsoft Most Valuable Professional (MVP) Award for the years 2015 and 2016 in Visual Studio and Development Technologies, also I’m a regular speaker in local events and Dev user groups.

Comments and Discussions

 
Questiongetting cors issue Pin
MehtaVikas21-Dec-15 7:42
MehtaVikas21-Dec-15 7:42 
GeneralGreat tutorial Pin
Member 115242274-Sep-15 8:29
Member 115242274-Sep-15 8:29 
GeneralGreat and creative Pin
Member 115242274-Sep-15 8:23
Member 115242274-Sep-15 8:23 
QuestionHow can I implement this with VS 2010 and WebAPI 1 Pin
Member 811114211-Aug-15 11:30
Member 811114211-Aug-15 11:30 
QuestionRegistration error (200k but returning nothing, browser shows json error) Pin
Member 1130693222-Jul-15 0:10
Member 1130693222-Jul-15 0:10 
Questiontoken lifecycle on IIS 8.5 Pin
Member 113182008-Jun-15 11:14
Member 113182008-Jun-15 11:14 
AnswerRe: token lifecycle on IIS 8.5 Pin
Member 115242274-Sep-15 8:28
Member 115242274-Sep-15 8:28 
QuestionPlaintext Password? Pin
whatispunk6-Apr-15 9:09
whatispunk6-Apr-15 9:09 
AnswerRe: Plaintext Password? Pin
Member 863380714-Apr-15 4:20
Member 863380714-Apr-15 4:20 
GeneralRe: Plaintext Password? Pin
Taiseer Joudeh4-Sep-16 22:50
professionalTaiseer Joudeh4-Sep-16 22:50 
BugBootstrap menu doesn't work Pin
bortbrain10-Mar-15 2:37
bortbrain10-Mar-15 2:37 
GeneralMy vote of 5 Pin
Manuel González6-Mar-15 1:20
Manuel González6-Mar-15 1:20 
QuestionLogin after sign up Pin
Dabiel11-Dec-14 22:07
Dabiel11-Dec-14 22:07 
QuestionIE Authentication Caching Pin
savage20148-Dec-14 0:16
savage20148-Dec-14 0:16 
GeneralCool one Pin
Gaurav Aroraa27-Oct-14 10:29
professionalGaurav Aroraa27-Oct-14 10:29 
GeneralRe: Cool one Pin
Taiseer Joudeh8-Nov-14 9:22
professionalTaiseer Joudeh8-Nov-14 9:22 
QuestionI keep getting this error: Unknown provider: LocalStorageServiceProvider <- LocalStorageService <- authService Pin
Angel Hawks21-Oct-14 9:54
Angel Hawks21-Oct-14 9:54 
QuestionIs Part 1 Still Available? Pin
Jim North10-Sep-14 12:41
Jim North10-Sep-14 12:41 
GeneralRe: Is Part 1 Still Available? Pin
PIEBALDconsult10-Sep-14 16:22
mvePIEBALDconsult10-Sep-14 16:22 
AnswerRe: Is Part 1 Still Available? Pin
Taiseer Joudeh11-Sep-14 0:41
professionalTaiseer Joudeh11-Sep-14 0:41 
QuestionHow is the token actually verified on each call to the server ? Pin
peterbrooke111-Aug-14 15:09
peterbrooke111-Aug-14 15:09 
AnswerRe: How is the token actually verified on each call to the server ? Pin
Taiseer Joudeh23-Aug-14 22:20
professionalTaiseer Joudeh23-Aug-14 22:20 
QuestionNew ASP.NET MVC Project Pin
jnelson99928-Jul-14 8:37
jnelson99928-Jul-14 8:37 
Can you explain how to configure a newly created MVC project to authenticate with your webapi project? I am trying to authenticate to the webapi project without clientside script...I would like to use the built in Account Controller so I limit what the user is able to see and do based up on roles or claims or limit the user's experience to a specific MVC Area. I would like to know how to establish a valid session in server side code of the new MVCWebapp project, and then potentially pass the token to the script for dataaccess via angular or backbone.

Thanks.
AnswerRe: New ASP.NET MVC Project Pin
Taiseer Joudeh4-Aug-14 8:37
professionalTaiseer Joudeh4-Aug-14 8:37 
Questioncan you explain Interceptor push operation Pin
mahmutesitmez200613-Jun-14 2:54
professionalmahmutesitmez200613-Jun-14 2:54 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.