Click here to Skip to main content
15,860,859 members
Articles / All Topics

ASP.NET Identity 2.0: Setting Up Account Validation and Two-Factor Authorization

Rate me:
Please Sign up or sign in to vote.
4.96/5 (29 votes)
9 Jun 2014CPOL12 min read 147.9K   59   27
With the release of the Identity 2.0 framework in March of 2014, the Identity team has added a significant set of new features to the previously simple, but somewhat minimal ASP.NET Identity system. Some of the most visible, and in-demand features introduced with the new release are account validati
 
 
 

4097092685_cd75ff7679_z

With the release of the Identity 2.0 framework in March of 2014, the Identity team has added a significant set of new features to the previously simple, but somewhat minimal ASP.NET Identity system. Some of the most visible, and in-demand features introduced with the new release are account validation and two-factor authorization.

The Identity team has created a terrific sample project/template which can effectively serve as the starting point for just about any ASP.NET MVC project you wish to build out with Identity 2.0 features. In this post, we will look at implementing email account validation as part of the account creation process, and two-factor authorization.

Image by Lucas | Some Rights Reserved

Previously, we took a very high-level tour of some of the new features available in Identity 2.0, and how key areas differ from the previous version 1.0 release. In this article, we'll take a close look at implementing Email Account Validation, and Two-Factor Authentication.

Getting Started: Create the Sample Project Using Nuget

To get started and follow along, create an empty ASP.NET project in Visual Studio (not an MVC project, use the Empty project template when creating a new project). Then, open the Package Manager Console and type:

Install the Sample Project from Nuget:
C#
PM> Install-Package Microsoft.AspNet.Identity.Samples -Pre

NOTE: A small number of readers have reported odd behavior with the Nuget download and install. If you experience any oddities as part of this process, please try to note in the comments section what happened, what VS version you are using, and any other configuration items you feel may be of note. Make sure to use a completely empty ASP.NET Project as your starting point!

Once Nuget has done its thing, you should see a familiar ASP.NET MVC project structure in the Solution Explorer:

ASP.NET Identity 2.0 Sample Project in Solution Explorer:
installed-project-in-solution-explorer

This structure should look mostly familiar if you have worked with a standard ASP.NET MVC project before. There are, however, a few new items in there, if you look closely. For our purposes in this article, we are primarily concerned with the IdentityConfig.cs file, located in the App_Start folder.

If we open the IdentityConfig.cs file and scroll through, we find two services classes defined, EmailService and SmsService:

The Email Service and SMS Service Classes:
C#
public class EmailService : IIdentityMessageService
{
    public Task SendAsync(IdentityMessage message)
    {
        // Plug in your email service here to send an email.
        return Task.FromResult(0);
    }
}
  

public class SmsService : IIdentityMessageService
{
    public Task SendAsync(IdentityMessage message)
    {
        // Plug in your sms service here to send a text message.
        return Task.FromResult(0);
    }
}

Notice the both EmailService and SmsService implement the common interface, IIdentityMessageService. We can use IIdentityMessageService to create any number of Mail, SMS, or other messaging implementations. We'll see how this works momentarily.

For our purposes, it is enough to observe that both the email account validation and two-factor authentication features rely on setting up the EmailService and SmsService and making them available within the application.  

Set Up A Default Admin Account

Before you go too much further, we need to set up a default Admin account that will be deployed when the site runs. The example project is set up so that the database is initialized when the project is run, and/or anytime the Code-First model schema changes. We need a default Admin user so that once the site runs, you can log in and do admin-type things, and you can't do THAT until you have a registered admin account.

As with the previous version of Identity, the default user account created during on-line registration has no admin privileges. Unlike the previous version, the first user created when the site is run is NOT an admin user. We need to seed the database during initialization.

NOTE: CP User Excheque has pointed out what may be a bug in the Identity 2.0 Sample project code (this IS an alpha (now beta) release). When the initial admin user is created, the EmailConfirmed property is never set, which compromises this user's ability to perform certain functions. I've updated the code below to set the property. 

Take a look at ApplicationDbInitializer class, also defined in IdentityConfig.cs:

The Application Db Initializer Class:
C#
public class ApplicationDbInitializer 
    : DropCreateDatabaseIfModelChanges<ApplicationDbContext> 
{
    protected override void Seed(ApplicationDbContext context) {
        InitializeIdentityForEF(context);
        base.Seed(context);
    }
  

    //Create User=Admin@Admin.com with password=Admin@123456 in the Admin role        
    public static void InitializeIdentityForEF(ApplicationDbContext db) 
    {
        var userManager = 
            HttpContext.Current.GetOwinContext().GetUserManager<ApplicationUserManager>();
 
        var roleManager = 
            HttpContext.Current.GetOwinContext().Get<ApplicationRoleManager>();

        const string name = "admin@admin.com";
        const string password = "Admin@123456";
        const string roleName = "Admin";

        //Create Role Admin if it does not exist
        var role = roleManager.FindByName(roleName);

        if (role == null) 
        {
            role = new IdentityRole(roleName);
            var roleresult = roleManager.Create(role);
        }

        var user = userManager.FindByName(name);

        if (user == null) 
        {
            user = new ApplicationUser { UserName = name, Email = name };
            var result = userManager.Create(user, password);
            
            // Set Email confirmation property (see note above):
            user.EmailConfirmed = true;
            result = userManager.SetLockoutEnabled(user.Id, false);
        }
  
        // Add user admin to Role Admin if not already added
        var rolesForUser = userManager.GetRoles(user.Id);

        if (!rolesForUser.Contains(role.Name)) 
        {
            var result = userManager.AddToRole(user.Id, role.Name);
        }
    }
}

The constants declared right after the roleManager initialization define the default admin user that will be created when the application is run for the first time. Before we proceed, change these to suit your own needs, and ideally, include a live, working email address.

Account Validation: How it works

You are no doubt familiar with email-based account validation. You create an account at some website, and a confirmation email is sent to your email address with a link you need to follow in order to confirm your email address and validate your account.

If we look at the AccountController in the example project, we find the Register() method:

The Register Method on Account Controller:
C#
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Register(RegisterViewModel model)
{
    if (ModelState.IsValid)
    {
        var user = new ApplicationUser { UserName = model.Email, Email = model.Email };
        var result = await UserManager.CreateAsync(user, model.Password);

        if (result.Succeeded)
        {
            var code = await UserManager.GenerateEmailConfirmationTokenAsync(user.Id);
            var callbackUrl = Url.Action("ConfirmEmail", "Account", 
                new { userId = user.Id, code = code }, 
                protocol: Request.Url.Scheme);

            await UserManager.SendEmailAsync(user.Id, 
                "Confirm your account", 
                "Please confirm your account by clicking this link: <a href=\"" 
                + callbackUrl + "\">link</a>");

            ViewBag.Link = callbackUrl;
            return View("DisplayEmail");
        }

        AddErrors(result);
    }
    // If we got this far, something failed, redisplay form
    return View(model);
}

Looking close at the above, we see a call to UserManager.SendEmailAsync where we pass in some arguments. Within AccountController, UserManager is a property which returns an instance of type ApplicationUserManager. If we take a look in the IdentityConfig.cs file in the App_Start folder, we find the definition for ApplicationUserManager. On this class, the static Create() method initializes and returns a new instance of ApplicationUserManager, and this is where our messaging services are configured:

The Create() Method Defined on the ApplicationUserManager Class:
C#
public static ApplicationUserManager Create(
    IdentityFactoryOptions<ApplicationUserManager> options, 
    IOwinContext context)
{
    var manager = new ApplicationUserManager(
        new UserStore<ApplicationUser>(
            context.Get<ApplicationDbContext>()));

    // Configure validation logic for usernames
    manager.UserValidator = new UserValidator<ApplicationUser>(manager)
    {
        AllowOnlyAlphanumericUserNames = false,
        RequireUniqueEmail = true
    };

    // Configure validation logic for passwords
    manager.PasswordValidator = new PasswordValidator
    {
        RequiredLength = 6, 
        RequireNonLetterOrDigit = true,
        RequireDigit = true,
        RequireLowercase = true,
        RequireUppercase = true,
    };

    // Configure user lockout defaults
    manager.UserLockoutEnabledByDefault = true;
    manager.DefaultAccountLockoutTimeSpan = TimeSpan.FromMinutes(5);
    manager.MaxFailedAccessAttemptsBeforeLockout = 5;

    // Register two factor authentication providers. This application 
    // uses Phone and Emails as a step of receiving a code for verifying the user
    // You can write your own provider and plug in here.
    manager.RegisterTwoFactorProvider(
        "PhoneCode", 
        new PhoneNumberTokenProvider<ApplicationUser>
    {
        MessageFormat = "Your security code is: {0}"
    });

    manager.RegisterTwoFactorProvider(
        "EmailCode", 
        new EmailTokenProvider<ApplicationUser>
    {
        Subject = "SecurityCode",
        BodyFormat = "Your security code is {0}"
    });

    manager.EmailService = new EmailService();
    manager.SmsService = new SmsService();
    var dataProtectionProvider = options.DataProtectionProvider;

    if (dataProtectionProvider != null)
    {
        manager.UserTokenProvider = 
            new DataProtectorTokenProvider<ApplicationUser>(
                dataProtectionProvider.Create("ASP.NET Identity"));
    }
    return manager;
}

Near the end of the method, we register our two-factor providers, and we set the Email and SMS services on the ApplicationUserManager instance.

From the above, we can see that the Register() method of AccountController calls the SendEmailAsync method of ApplicationUserManager, which has been configured with an Email and SMS service at the time it is created.

Email validation of new user accounts, two-factor authentication via email, and two-factor authentication via SMS Text all depend upon working implementations for the EmailService and SmsService classes.

Implementing the Email Service Using Your Own Mail Account

Setting up the email service for our application is a relatively simple task. You can use your own email account, or a mail service such as Sendgrid to create and send account validation or two-factor sign-in email. For example, if I wanted to use an Outlook.com email account to send confirmation email, I might configure my Email Service like so:

The Mail Service Configured to Use an Outlook.com Host:
C#
public class EmailService : IIdentityMessageService
{
    public Task SendAsync(IdentityMessage message)
    {
        // Credentials:
        var credentialUserName = "yourAccount@outlook.com";
        var sentFrom = "yourAccount@outlook.com";
        var pwd = "yourApssword";
 
        // Configure the client:
        System.Net.Mail.SmtpClient client = 
            new System.Net.Mail.SmtpClient("smtp-mail.outlook.com");

        client.Port = 587;
        client.DeliveryMethod = System.Net.Mail.SmtpDeliveryMethod.Network;
        client.UseDefaultCredentials = false;

        // Create the credentials:
        System.Net.NetworkCredential credentials = 
            new System.Net.NetworkCredential(credentialUserName, pwd);

        client.EnableSsl = true;
        client.Credentials = credentials;

        // Create the message:
        var mail = 
            new System.Net.Mail.MailMessage(sentFrom, message.Destination);

        mail.Subject = message.Subject;
        mail.Body = message.Body;

        // Send:
        return client.SendMailAsync(mail);
    }
}

The precise details for your SMTP host may differ, but you should be able to find documentation. As a general rule, though, the above is a good starting point.

Implementing the Email Service Using Sendgrid

There are numerous email services available, but Sendgrid is a popular choice in the .NET community. Sendgrid offers API support for multiple languages as well as an HTTP-based Web API. Additionally, Sendgrid offers direct integration with Windows Azure.

If you have a standard Sendgrid account (you can set up a free developer account at the Sendgrid site), setting up the Email Service is only slightly different than in the previous example. First off, your network credentials will simply use your Sendgrid user name and password. Note that in this case, your user name is different than the email address you are sending from. in fact, you can use any address as your sent from address.

The EmailService Configured to Use Sendgrid:
C#
public class EmailService : IIdentityMessageService
{
    public Task SendAsync(IdentityMessage message)
    {
        // Credentials:
        var sendGridUserName = "yourSendGridUserName";
        var sentFrom = "whateverEmailAdressYouWant";
        var sendGridPassword = "YourSendGridPassword";

        // Configure the client:
        var client = 
            new System.Net.Mail.SmtpClient("smtp.sendgrid.net", Convert.ToInt32(587));

        client.Port = 587;
        client.DeliveryMethod = System.Net.Mail.SmtpDeliveryMethod.Network;
        client.UseDefaultCredentials = false;

        // Creatte the credentials:
        System.Net.NetworkCredential credentials = 
            new System.Net.NetworkCredential(sendGridUserName, pwd);

        client.EnableSsl = true;
        client.Credentials = credentials;

        // Create the message:
        var mail = 
            new System.Net.Mail.MailMessage(sentFrom, message.Destination);

        mail.Subject = message.Subject;
        mail.Body = message.Body;

        // Send:
        return client.SendMailAsync(mail);
    }
}

In the above, we see all we really had to change were our credentials, and the SMTP host string.

With that done, we can give our email service a quick trial run.

Testing Account Confirmation Using the Email Service

First, let's run our application, and attempt to register a new user. User a working email address to which you have access. If all goes well you will be redirected to a view resembling the following:

Redirect to Confirmation Sent View:

redirect-to-confirmation-view

As you can see, there is some language there indicating that you can click the link in the view to confirm account creation for the purpose of the demo. However, an actual confirmation email should have been sent to the address you specified when creating the account, and following the confirmation link will, in fact, confirm the account and allow you to log in.

Once we have our site working properly, we will obviously want to do away with the demo link, and also change the text on this view. We'll look at that shortly.

Implementing the SMS Service

To use two-factor authentication with SMS/Text, you will need an SMS host provider, such as Twilio. Like Sendgrid, Twilio is quite popular in the .NET community, and offers a comprehensive C# API. You can sign up for a free account at the Twilio Site.

When you create a Twilio account, you will be issued an SMS phone number, an account SID, and an Auth Token. You can find your Twilio SMS phone number by logging into your account, and navigating to the Numbers tab:

Locate Twilio SMS Number:

twilio-sms-number

Likewise, you can find your SID and Auth Token on the Dashboard Tab:

Locate Twilio SID and Auth Token:

twilio-sid-and-auth-token

In the above, see the little padlock icon next to the Auth Token? If you click that, your token will be visible, and can be copied for pasting into your code.

Once you have created an account, in order to use Twilio from within your application, you will need to get the Twilio Nuget package:

Add the Twilio Nuget Package Using Package Manager Console:
C#
Install-Package Twilio

That done, we can add Twilio to the using statements at the top of our IdentityConfig.cs file, and then implement the SmsService as follows:

The SMS Service Using Twilio:
C#
public class SmsService : IIdentityMessageService
{
    public Task SendAsync(IdentityMessage message)
    {
        string AccountSid = "YourTwilioAccountSID";
        string AuthToken = "YourTwilioAuthToken";
        string twilioPhoneNumber = "YourTwilioPhoneNumber";

        var twilio = new TwilioRestClient(AccountSid, AuthToken);
        twilio.SendSmsMessage(twilioPhoneNumber, message.Destination, message.Body); 

        // Twilio does not return an async Task, so we need this:
        return Task.FromResult(0);
    }
}

Now that we have both an email service and an SmsService configured, we can see if our two-factor authentication works as expected.

Testing Two-Factor Authentication

The example project is set up so that two-factor authentication is an "opt-in" feature per user. To see if everything is working properly, log in using either your default admin account, or the account you created previously when testing out the email validation feature. Once logged in, navigate to the user account area by clicking on the user name displayed top right in the browser view. You should see something like this:

The Manage User Account View:

enable-two-factor-auth

In the above window, you will want to enable two-factor authentication, and add your phone number. When you go to add your phone number, you will be sent an SMS message to confirm. If we have done everything correctly so far, this should only take a few seconds (sometimes it can be more than a few seconds, but in general, if 30 or more seconds goes by, something is probably wrong . . .).

Once two-factor auth is enabled, you can log out, and try logging in again. You will be offered a choice, via a drop-down menu, where you can choose to receive your two-factor code via email or SMS:

Select Two-Factor Authentication Method:

two-factor-choose-medium

Once you make your selection above, the two-factor code will be sent to your selected provider, and you can complete the login process.

Remove the Demo Shortcuts

As mentioned previously, the example project comes with some shortcuts built-in so that, for development and testing purposes, it is not necessary to actually send or retreive the two-factor code (or follow the account validation link from an actual email) from an email or SMS account.

Once we are ready to deploy, we will want to remove these shortcuts from the corresponding views, and also the code which passes the links/codes to the ViewBag. For example, if we take another look at the Register() method on AccountController, we can see the following code in the middle of the method:

C#
if (result.Succeeded)
{
    var code = await UserManager.GenerateEmailConfirmationTokenAsync(user.Id);
    var callbackUrl = 
        Url.Action("ConfirmEmail", "Account", 
            new { userId = user.Id, code = code }, protocol: Request.Url.Scheme);

    await UserManager.SendEmailAsync(user.Id, "Confirm your account", 
        "Please confirm your account by clicking this link: <a href=\"" + callbackUrl + "\">link</a>");

    // This should not be deployed in production:
    ViewBag.Link = callbackUrl;
    return View("DisplayEmail");
}

AddErrors(result);

The highlighted lines should be commented out or deleted in production.

Likewise, the View that is returned by the Register() method, DisplayEmail.cshtml, will need some adjustment as well:

The DisplayEmail View:
C#
@{
    ViewBag.Title = "DEMO purpose Email Link";
}
<h2>@ViewBag.Title.</h2>
<p class="text-info">
    Please check your email and confirm your email address.
</p>
<p class="text-danger">
    For DEMO only: You can click this link to confirm the email: <a href="@ViewBag.Link">link</a>
    Please change this code to register an email service in IdentityConfig to send an email.
</p>

In a similar manner, the controller method VerifyCode() also pushes the two-factor code out into the view for ease of use during development, but we absolutely don't want this behavior in production:

The Verify Code Method on AccountController:
C#
[AllowAnonymous]
public async Task<ActionResult> VerifyCode(string provider, string returnUrl)
{
    // Require that the user has already logged in via username/password or external login
    if (!await SignInHelper.HasBeenVerified())
    {
        return View("Error");
    }

    var user = 
        await UserManager.FindByIdAsync(await SignInHelper.GetVerifiedUserIdAsync());

    if (user != null)
    {
        ViewBag.Status = 
            "For DEMO purposes the current " 
            + provider 
            + " code is: " 
            + await UserManager.GenerateTwoFactorTokenAsync(user.Id, provider);
    }
    return View(new VerifyCodeViewModel { Provider = provider, ReturnUrl = returnUrl });
}

Also, as with the Register() method, we want to make suitable adjustments to the VerifyCode.cshtml View:

The VerifyCode.cshtml View:
C#
@model IdentitySample.Models.VerifyCodeViewModel
@{
    ViewBag.Title = "Enter Verification Code";
}

<h2>@ViewBag.Title.</h2>

@using (Html.BeginForm("VerifyCode", "Account", new { ReturnUrl = Model.ReturnUrl }, FormMethod.Post, new { @class = "form-horizontal", role = "form" })) {
    @Html.AntiForgeryToken()
    @Html.ValidationSummary("", new { @class = "text-danger" })
    @Html.Hidden("provider", @Model.Provider)
    <h4>@ViewBag.Status</h4>
    <hr />

    <div class="form-group">
        @Html.LabelFor(m => m.Code, new { @class = "col-md-2 control-label" })
        <div class="col-md-10">
            @Html.TextBoxFor(m => m.Code, new { @class = "form-control" })
        </div>
    </div>
    <div class="form-group">
        <div class="col-md-offset-2 col-md-10">
            <div class="checkbox">
                @Html.CheckBoxFor(m => m.RememberBrowser)
                @Html.LabelFor(m => m.RememberBrowser)
            </div>
        </div>
    </div>
    <div class="form-group">
        <div class="col-md-offset-2 col-md-10">
            <input type="submit" class="btn btn-default" value="Submit" />
        </div>
    </div>
}

Again, the highlighted line could prove troublesome, and should be removed.

Keep Mail and SMS Account Settings Secure

The examples in this article have account user names and passwords, Auth tokes, and such hard-coded into the methods in which they are used. It is unlikely we would want to deploy this way, and equally unlikely we would even want to push our code into source control with these things exposed in such a manner (ESPECIALLY to Github!!).

Far better to put any private settings in a secure location. See Keep Private Settings Out of Source Control for more information.

The Tip of the Iceberg

ASP.NET Identity 2.0 has introduced a number of exciting new features previously not available as "out-of-the-box" features available to ASP.NET developers. Email account validation and two-factor authentication are but two of the most visible, and easily implemented (at least, within the framework of the example project provided by the Identity team!).

New features such as these have added greatly to the security arsenal available to the average developer, but have also added complexity to development of sites which utilize Identity 2.0. The Identity 2.0 team has provided some powerful security tools, but it may be easier to introduce new and harder to find security holes if we are not careful.

Additional Resources and Items of Interest

The Following Focus on Identity 1.0:

License

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


Written By
Software Developer XIV Solutions
United States United States
My name is John Atten, and my username on many of my online accounts is xivSolutions. I am Fascinated by all things technology and software development. I work mostly with C#, Javascript/Node.js, Various flavors of databases, and anything else I find interesting. I am always looking for new information, and value your feedback (especially where I got something wrong!)

Comments and Discussions

 
QuestionLovely piece of work, just one problem left for me Pin
Member 1190676213-Jan-17 0:58
Member 1190676213-Jan-17 0:58 
GeneralMy vote of 5 Pin
harveyt29-Mar-15 10:28
harveyt29-Mar-15 10:28 
QuestionHow to add Email link expire code Pin
viprat30-Jan-15 2:40
viprat30-Jan-15 2:40 
QuestionAn attempt was made to access a socket in a way forbidden by its access permissions Pin
Member 1053932412-Dec-14 1:36
Member 1053932412-Dec-14 1:36 
AnswerRe: An attempt was made to access a socket in a way forbidden by its access permissions Pin
John Atten12-Dec-14 1:51
John Atten12-Dec-14 1:51 
QuestionNuGet sample install fails Pin
Gary In SD23-Sep-14 7:12
Gary In SD23-Sep-14 7:12 
AnswerRe: NuGet sample install fails Pin
John Atten23-Sep-14 8:13
John Atten23-Sep-14 8:13 
QuestionConfirmation email Pin
Member 1101825117-Aug-14 15:21
Member 1101825117-Aug-14 15:21 
AnswerRe: Confirmation email Pin
John Atten18-Aug-14 1:47
John Atten18-Aug-14 1:47 
GeneralRe: Confirmation email Pin
Member 1101945318-Aug-14 2:14
Member 1101945318-Aug-14 2:14 
GeneralRe: Confirmation email Pin
John Atten18-Aug-14 2:27
John Atten18-Aug-14 2:27 
GeneralRe: Confirmation email Pin
Member 1101945318-Aug-14 2:34
Member 1101945318-Aug-14 2:34 
QuestionRemember me after user deleted Pin
KevinSwarts11-Jun-14 5:54
KevinSwarts11-Jun-14 5:54 
AnswerRe: Remember me after user deleted Pin
John Atten12-Jun-14 3:50
John Atten12-Jun-14 3:50 
QuestionForgot Password bug Pin
Xcheque6-Jun-14 18:46
Xcheque6-Jun-14 18:46 
AnswerRe: Forgot Password bug Pin
John Atten6-Jun-14 19:10
John Atten6-Jun-14 19:10 
GeneralRe: Forgot Password bug Pin
Xcheque7-Jun-14 23:15
Xcheque7-Jun-14 23:15 
QuestionTraps for young players? Pin
Xcheque2-Jun-14 22:48
Xcheque2-Jun-14 22:48 
AnswerRe: Traps for young players? Pin
John Atten3-Jun-14 3:13
John Atten3-Jun-14 3:13 
QuestionThank you, John Atten! Works great, well done! Pin
John Korondy25-Apr-14 1:31
John Korondy25-Apr-14 1:31 
AnswerRe: Thank you, John Atten! Works great, well done! Pin
John Atten25-Apr-14 1:54
John Atten25-Apr-14 1:54 
QuestionAs of April 24 samples are not downloadable Pin
crystal915424-Apr-14 6:08
crystal915424-Apr-14 6:08 
AnswerRe: As of April 24 samples are not downloadable Pin
John Atten24-Apr-14 7:08
John Atten24-Apr-14 7:08 
QuestionInstall-Package : Unable to find package 'Microsoft.AspNet.Identity.Samples'. Pin
John Korondy22-Apr-14 5:33
John Korondy22-Apr-14 5:33 
AnswerRe: Install-Package : Unable to find package 'Microsoft.AspNet.Identity.Samples'. Pin
John Atten22-Apr-14 6:03
John Atten22-Apr-14 6:03 

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.