Forward network credentials to Report Server

I am on a quest to deliver SSRS reports within a web application. So far we have authenticated against Active Directory using Forms Auth. Now we need to embed a report in our app. Then, we’ll need to forward the logged-on user’s AD credentials to the report server so he can access the report.

image image image

For now, I’m just going to add a report to Default.aspx. Later on, we’ll provide a menu to navigate the user’s reports. First, drag a “MicrosoftreportViewer” control from the Toolbox onto the designer. Then, click the task button, open “Choose Report”, and select “<Server Report>”. Fill in your Report Server URL, which you can get by launching “Reporting Services Configuration Manager” and selecting the Web Service URL. Enter the path to the report that you created earlier. Your page source should look like this.

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Default.aspx.cs" Inherits="WebApplication1._Default" %>

<%@ Register assembly="Microsoft.ReportViewer.WebForms, Version=9.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" namespace="Microsoft.Reporting.WebForms" tagprefix="rsweb" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<html xmlns="http://www.w3.org/1999/xhtml" >
<head runat="server">
    <title></title>
</head>
<body>
    <form id="form1" runat="server">
    <div>

        <rsweb:ReportViewer ID="ReportViewer1" runat="server" Font-Names="Verdana"
            Font-Size="8pt" Height="400px" Width="100%" ProcessingMode="Remote">
            <ServerReport ReportPath="/Admin Report"
                ReportServerUrl="http://dit3074lt2:8080/ReportServer_SQL2008R2" />
        </rsweb:ReportViewer>

    </div>
    </form>
</body>
</html>

If you run the application right now, it looks like it all works! That’s because you are running a local web server under your own account. Your credentials are passed to the report server. We want to pass the logged-in user’s credentials to Report Server, so he can only access the reports to which he has been given permission.

Provide credentials to report Server

To pass credentials to Report Server, we set the ReportViewer.ServerReport.ReportServerCredentials property to an IReportServerCredentials. There are many ways to implement this interface. The simplest way is to provide a NetworkCredentials object.

public class NetworkReportServerCredentials : IReportServerCredentials
{
    private string _userName;
    private string _password;
    private string _domain;

    public NetworkReportServerCredentials(string userName, string password, string domain)
    {
        _userName = userName;
        _password = password;
        _domain = domain;
    }

    public bool GetFormsCredentials(out Cookie authCookie, out string userName, out string password, out string authority)
    {
        authCookie = null;
        userName = null;
        password = null;
        authority = null;
        return false;
    }

    public WindowsIdentity ImpersonationUser
    {
        get { return null; }
    }

    public ICredentials NetworkCredentials
    {
        get { return new NetworkCredential(_userName, _password, _domain); }
    }
}

This implementation only provides a meaningful implementation to the NetworkCredentials property. It just returns the credentials that it was given. We have to get the user’s credentials into this object when we access the report. We captured the user’s credentials when he logged in, but we didn’t save them anywhere. We need to save them during login so we can access them while viewing the report.

During login, we have the opportunity to populate a FormsAuthenticationTicket. This is the object that gets stored in the user’s Forms Authorization cookie. This class holds the user’s name, but not their password. However, it does have a UserData property that we can use however we want.

Cryptography required

Now, we could just put the plaintext password into UserData. But we aren’t going to do that. This object is cached as a cookie in the user’s browser. Storing their plaintext password in a cookie would reveal it to anyone who had access to their machine. Even without direct access to the machine, a cross-site scripting attack could compel the browser to give up its cookie. We will not expose the user to that kind of vulnerability.

Instead, we are going to encrypt the password using a secret key that we store on the server. An attacker would need access to this key if they were going to pull the user’s password from their cookie. First, we create some useful crypto helpers:

public class Crypto
{
    public static byte[] EncryptMessage(
        string messageIn,
        SymmetricAlgorithm symmetricAlgorithm,
        byte[] key,
        byte[] initializationVector)
    {
        MemoryStream memoryStream = new MemoryStream();
        using (StreamWriter cryptoWriter = new StreamWriter(
            new CryptoStream(
                memoryStream,
                symmetricAlgorithm.CreateEncryptor(key, initializationVector),
                CryptoStreamMode.Write)))
        {
            cryptoWriter.Write(messageIn);
        }

        return memoryStream.ToArray();
    }

    public static string DecryptMessage(
        byte[] encryptedMessage,
        SymmetricAlgorithm symmetricAlgorithm,
        byte[] key,
        byte[] initializationVector)
    {
        MemoryStream memoryStream = new MemoryStream(encryptedMessage);
        using (StreamReader cryptoReader = new StreamReader(
            new CryptoStream(
                memoryStream,
                symmetricAlgorithm.CreateDecryptor(key, initializationVector),
                CryptoStreamMode.Read)))
        {
            return cryptoReader.ReadToEnd();
        }
    }

    public static byte[] ComputeStringHash(string message, HashAlgorithm hashAlgorith)
    {
        byte[] messageBytes = ASCIIEncoding.ASCII.GetBytes(message);
        return hashAlgorith.ComputeHash(messageBytes);
    }
}

The first two methods encrypt and decrypt a string using a symmetrical algorithm. The encrypted message is binary, so it is represented as a byte array. I found it exceedingly difficult to get these steps right, even though the code turned out to be almost trivial. The third method hardly deserves to be included with the others, but it comes in handy.

Let’s see how these methods are used.

[TestClass]
public class CryptoStreamTest
{
    private const string PreGeneratedKey = @"s03UsP/dHD0=";
    private SymmetricAlgorithm _symmetricAlgorithm = new DESCryptoServiceProvider();
    private HashAlgorithm _hashAlgorith = new MD5CryptoServiceProvider();
    private RandomNumberGenerator _randomNumberGenerator = new RNGCryptoServiceProvider();
    private static byte[] _key = Convert.FromBase64String(PreGeneratedKey);

    public TestContext TestContext { get; set; }

    [TestMethod]
    public void GenerateKey()
    {
        byte[] key = new byte[_symmetricAlgorithm.KeySize / 8];
        _randomNumberGenerator.GetBytes(key);
        string encodedKey = Convert.ToBase64String(key);
        Assert.AreNotEqual(PreGeneratedKey, encodedKey);
        Console.WriteLine(encodedKey);
    }

    [TestMethod]
    public void EncryptAndDecryptStream()
    {
        byte[] initializationVector = new byte[_symmetricAlgorithm.KeySize / 8];
        _randomNumberGenerator.GetBytes(initializationVector);
        byte[] encryptedMessage = Crypto.EncryptMessage("plaintext", _symmetricAlgorithm, _key, initializationVector);
        string messageOut = Crypto.DecryptMessage(encryptedMessage, _symmetricAlgorithm, _key, initializationVector);

        Assert.AreEqual("plaintext", messageOut);
    }

    [TestMethod]
    public void InitializationVectorIsImportant()
    {
        byte[] initializationVector1 = new byte[_symmetricAlgorithm.KeySize / 8];
        _randomNumberGenerator.GetBytes(initializationVector1);
        byte[] initializationVector2 = new byte[_symmetricAlgorithm.KeySize / 8];
        _randomNumberGenerator.GetBytes(initializationVector2);
        byte[] encryptedMessage = Crypto.EncryptMessage("plaintext", _symmetricAlgorithm, _key, initializationVector1);
        string messageOut = Crypto.DecryptMessage(encryptedMessage, _symmetricAlgorithm, _key, initializationVector2);

        Assert.AreNotEqual("plaintext", messageOut);
    }

    [TestMethod]
    public void HashMessage()
    {
        byte[] hash = Crypto.ComputeStringHash("This is the string that we intend to sign.", _hashAlgorith);
        string encodedHash = Convert.ToBase64String(hash);
        Assert.AreEqual(@"04fj0UWULE9imGTrHRUw5g==", encodedHash);
    }
}

To generate a key, we use the cryptographic random number generator RNGCryptoServiceProvider. This produces a binary array of the key length required by our symmetrical encryption algorithm. Convert the binary array to a base 64 string for easy portability. I generated one ahead of time for use with the remaining tests.

To encrypt and decrypt a string, we must provide not only a key but also an initialization vector. The initialization vector is a starting point for the symmetrical algorithm. Encryption and decryption must both start at the same point. The trick is that we don’t want to use the same initialization vector every time, because that would mean we always produce the same cyphertext for a given plaintext. An attacker could simply create an account with a common password (say, “password”), and then look for other users with the same cyphertext as he has. Bingo! He knows that their password is “password”!

Finally, we test computing the hash of a string. This is usually used to digitally sign a message, but we have another use for it.

Encrypt the user’s password

We want to encrypt the user’s password. For that we’ll need a key; you can generate one with the first unit test above. But we will also need an initialization vector. Remember, we need to use the same initialization vector for encrypting as well as decrypting. And we’ll also need it to be different for each user. The simple solution: use the hash of the username.

protected void Login1_Authenticate(object sender, AuthenticateEventArgs e)
{
    string username = Login1.UserName;
    string password = Login1.Password;

    // MLP: Encrypt the password.
    byte[] usernameHash = Crypto.ComputeStringHash(username, _hashAlgorith);
    byte[] encryptedMessage = Crypto.EncryptMessage(password, _symmetricAlgorithm, _key, usernameHash);
    string encryptedPassword = Convert.ToBase64String(encryptedMessage);

    if (Membership.ValidateUser(username, password))
    {
        e.Authenticated = true;

        FormsAuthenticationTicket ticket = new FormsAuthenticationTicket(
            1,
            username,
            DateTime.Now,
            DateTime.Now.AddMinutes(30),
            false,
            encryptedPassword,
            FormsAuthentication.FormsCookiePath);

        // MLP: This method should be called "Encode", not "Encrypt".
        string encTicket = FormsAuthentication.Encrypt(ticket);

        // Create the cookie.
        Response.Cookies.Add(new HttpCookie(FormsAuthentication.FormsCookieName, encTicket));

        // Redirect back to original URL.
        Response.Redirect(FormsAuthentication.GetRedirectUrl(username, false));
    }
    else
    {
        e.Authenticated = false;
    }
}

Hold on a second. If we are calling FormsAuthentication.Encrypt(), why are we bothering to encrypt the password first? Unfortunately, Encrypt() is not secure. It doesn’t take a key. That means that anyone with access to the .NET Framework can simply call Decrypt() to get back the ticket. I would prefer if the method was called Encode().

Decrypt the user’s password

The last step is to access the encrypted password, decrypt it, and pass the credentials to Report Server. We accomplish this with a minimum of code:

protected void Page_Load(object sender, EventArgs e)
{
    // MLP: Get the user's credentials from forms auth.
    IIdentity identity = HttpContext.Current.User.Identity;
    FormsIdentity formsIdentity = (FormsIdentity)identity;
    string username = formsIdentity.Name;
    string encryptedPassword = formsIdentity.Ticket.UserData;

    // MLP: Decrypt the password.
    byte[] usernameHash = Crypto.ComputeStringHash(username, _hashAlgorith);
    byte[] encryptedMessage = Convert.FromBase64String(encryptedPassword);
    string password = Crypto.DecryptMessage(encryptedMessage, _symmetricAlgorithm, _key, usernameHash);

    IReportServerCredentials credentials = new NetworkReportServerCredentials(username, password, "ABSG");
    ReportViewer1.ServerReport.ReportServerCredentials = credentials;
}

And so we have passed the user’s network credentials on to Report Server. They were authenticated against Active Directory, which is the identity provider that SSRS prefers. However, we did all this in forms authentication so that our web application works better with users on the Internet.

Next steps

Next we are going to provide a menu of reports to the user. The business administrator should be able to define new reports without requiring any change to our application.

4 Responses to “Forward network credentials to Report Server”

  1. tootoomike Says:

    Michael, thanks very much for this excellent article, amazingly this is the only good, clear and simple explanation of consuming SRSS from an asp.net web app (I couldn't find anything on MSDN for example), if the SRSS is on a different unconnected domain. The example of the NetworkReportServerCredentials class is excellent, and has enabled me to use a SRSS web reference ReportingService2010 class to retrieve reportlisting to enable user selection...(is this something you've considered?), something like this...

    internal static ReportingService2010WebReference.CatalogItem[] GetNavBarGroupForReportFolder(string reportDir, string groupTitle)
    {
    ReportingService2010WebReference.CatalogItem[] result = null;

    ReportingService2010WebReference.ReportingService2010 rs = new ReportingService2010WebReference.ReportingService2010();
    rs.Credentials = new NetworkReportServerCredentials("username", "password", "domain").NetworkCredentials;
    result = rs.ListChildren(reportDir, false);

    return result;
    }

    cheers mate, great work
    Mike

  2. Michael L Perry Says:

    Funny you should mention that. Check out the follow up posts:
    http://adventuresinsoftware.com/blog/?p=542
    http://adventuresinsoftware.com/blog/?p=547
    http://adventuresinsoftware.com/blog/?p=549

    Glad it helped.

  3. Graham Says:

    This is an absolutely brilliant article and exactly what I've been looking for!
    Spent an entire day Googling, came back into work the next day and came across this page after a few clicks.
    Many thanks for publishing this - it is something that should definitely be on MSDN by default.

  4. Bakos Says:

    Great article. Thnx a lot. I was looking for hours to understand the mechanism. However in visual studio 2010, I had to check whether it is a postback or not, in order to see the report, otherwise it was trying to load in an infinite loop. For more info, one can read also here http://blogs.msdn.com/b/brianhartman/archive/2010/03/21/reports-never-stop-loading-with-vs-2010.aspx

    Thnx again for the great article.

    Bakos

Leave a Reply

You must be logged in to post a comment.