Chapter 16

Extending Your Web Server with ISAPI Filters


After learning about the different types of ISAPI filters you can create, you are probably anxious to see some of these concepts come to life. This chapter gives you two sample ISAPI filter applications.

The first sample is a custom authentication scheme that is used with the standard Windows NT Internet Information Server (IIS) authentication.

The second sample shows how you can use an ISAPI filter to add a few custom pieces of information to your log files.

Both examples touch on key points that you can use to build many different types of ISAPI filters.

Building a Custom Authentication Filter

Have you ever visited a Web site and your browser pops up a dialog box asking for a user name and password? That Web site has asked your browser for credentials to ensure that you have access to the document you have requested.

Figure 16.1 shows an example of Microsoft's Internet Explorer (IE) browser prompting the user for authentication credentials.

Fig. 16.1.

Microsoft's Internet Explorer Authentication dialog box.

The default authentication scheme used by Microsoft's IIS uses file security based on Windows NT to determine access privileges for documents. When a browser requests a document, IIS first checks the credentials given by the browser.

Most browsers do not provide credentials when first requesting a document. In this case, IIS authenticates the document using the security context specified in the Anonymous Logon section of the properties dialog in the Microsoft Internet Service Manager, as shown in Figure 16.2.

Fig. 16.2

Service properties dialog box from Internet Service Manager.

Also pay close attention to the Password Authentication frame shown in Figure 16.2. You need to have the Allow Anonymous check box set if you have pages on your Web site that do not need authentication.

The Basic (Clear Text) check box needs to be set if you plan to provide standard Internet authentication, as shown in this chapter. The Windows NT Challenge/Response checkbox applies only to Microsoft's Internet Explorer (IE).

A browser that can use the Microsoft Windows NT Challenge/Response authentication does not prompt the user to enter credentials when connecting to a secured Web site with this kind of authentication scheme. Instead, the browser automatically authenticates using the credentials of the currently logged on account.

If you have this check box set and your clients will be connecting with Microsoft's IE, the client is never prompted to enter an alternate set of credentials. In other words, if you are writing your own authentication filter, you probably don't want this check box set.

All documents on your Web site should have read-only privileges assigned to the anonymous logon. If, however, this account does not have security privileges to read the document requested, IIS tells the browser that authentication is needed. The browser, in turn, presents the user with a dialog box that prompts for a user name and password, as shown above in Figure 16.1.

After the user enters a user name and password, the browser re-requests the same document, but this time includes the authentication credentials in the header of its request. IIS tries to read the document using the new credentials as its security context.

If security is sufficient, the document is returned to the browser. If not, IIS tells the browser that authentication failed.

Most browsers usually cache, by Web site, the last successfully authenticated credentials entered by the user. Therefore, it may seem as if a browser is not supplying credentials when it really is. Keep this in mind when you build your own authentication scheme.

The built-in IIS security system works well for restricting parts of your Web site to a few users. But what if you have a few thousand users who need secure access to a section of your Web site?

One solution, albeit a bad one, is to add a few thousand user accounts to your NT domain. Although Windows NT can handle many times more users than this, think of the nightmare it would be to administer all of these accounts.

Another rather poor solution is to create only one account for a restricted area and let all the users share the same user name and password for this account. There are a couple of problems with this solution.

First, you lose the ability to revoke access from only one user without informing all the others that you have changed the account. Second, and more important, each user looks the same. You've lost the ability to distinguish the identity of individual users.

Let's say you are designing a Web site for a bank. Wouldn't it be great to have the user enter his or her own account number and PIN, upon which an ISAPI filter would authenticate the customer, and an ISAPI extension would dynamically put together a Web page with the customer's account information?

You probably would not want to add all the bank customers into an NT domain. It's just not practical. However, there is probably a data source available with the customer's account number and PIN.

The ideal solution would be to have one Windows NT user account that secures a section of your Web site. You would have a process transparently transform a customer's user name and password into the generic account with the privilege to read a secure section of your site.

Then any extension you write could use the UNMAPPED_REMOTE_USER server variable to get the credentials originally used to request authentication. This is the solution presented in the following ISAPI filter application.

In this example we use an ODBC data source to do the translation from a logical user name and password to a physical Windows NT user name and password. Since a database hit can be resource- intensive, I've also supplied a class that caches authenticated credentials for a period of time.

In addition, since you may not want this translated at all sections of your Web site, I've also supplied a mechanism to selectively invoke this custom authentication scheme.

Before I show you the steps to create the CustomAuth filter, let me just give you a few implementation details. CustomAuth was compiled using Microsoft Visual C++ 4.1. I did not use the ISAPI application wizard that comes with Visual C++ 4.1, nor did I use the MFC ISAPI filter classes.

Although there's absolutely nothing wrong with using the MFC ISAPI classes, they are explained elsewhere in the book and they tend to hide what is really happening behind the scenes. As you will soon see, I used MFC for some utility functions for strings, maps, and thread synchronization.

Adding the Filter Entry Points

First, we create a new project workspace. Your target is a dynamic-link library (DLL). Figure 16.3 shows how the Microsoft Developer Studio should look when you first create the CustomAuth filter.

Fig. 16.3

Creating a new DLL.

Next, we add the standard ISAPI entry points. Every ISAPI filter must have two exported functions, which are defined in Listing 16.1.

Only two functions are ever called by the Web server. The first function, GetFilterVersion, is called only once when the Web server starts and the filter is first loaded. The second, HttpFilterProc, is called for every filter event. I'll explain these two functions in detail a bit later.

Listing 16.1 LST16_1.CPP-Declaring the Exported Functions

extern "C" {

BOOL WINAPI GetFilterVersion(PHTTP_FILTER_VERSION pVer);

DWORD WINAPI HttpFilterProc(HTTP_FILTER_CONTEXT* pfc,

DWORD NotificationType, VOID* pvNotification);

}

Let me first explain the extern "C" block. Since we are writing C++ code that is compiled by a C++ compiler, we tell the compiler that these two functions will be called using the "C" calling convention. If you omit this keyword, the compiler uses the default C++ naming convention, and the Web server can't find your exported functions.

Before we start writing code for these functions, we tell the compiler that these two functions will be exported. The normal method for exporting DLL functions is to declare them using the declspec(dllexport) attribute-but not in this case.

As you can see in Listing 16.1, these two functions have already been declared using the WINAPI macro, which expands to stdcall. You can't declare a function using both declspec(dllexport) and stdcall. The only other way to export a function is to include the exported functions in the contents of a .DEF file.

Including the file in Listing 16.2 into your project ensures that these two functions are properly exported.


[Tip]


You can use the DUMPBIN.EXE utility supplied with Visual C++ to show you the exported functions of a DLL. Use the DUMPBIN YourFile.DLL /EXPORTS syntax. When a filter does not load, it is often because you have improperly exported the GetFilterVersion function.


Listing 16.2 LST16_2.DEF-The CustomAuth.DEF File

LIBRARY "CustomAuth"

EXPORTS

GetFilterVersion

HttpFilterProc

Writing the GetFilterVersion Function

When Microsoft's IIS first starts, it opens the following registry key and reads the data stored in the Filter DLLs value.

HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\W3SVC\Parameters

You must put all ISAPI filters, separated by commas, in this registry value. Next, IIS calls the GetFilterVersion function for each filter DLL in this list.

It is each filter's responsibility to fill out the HTTP_FILTER_VERSION structure passed to it in the GetFilterVersion function. This structure informs IIS of the name of the filter, the version of the filter, the events of which this filter should be notified, and the priority of the filter.

The code in Listing 16.3 tells the Web server that we respond only to SF_NOTIFY_AUTHENTICATION events and that our filter should have the default SF_NOTIFY_ORDER_DEFAULT priority.

We also tell IIS that our filter is version 1.0 and its name is CustomAuth Filter. The return of TRUE from GetFilterVersion tells IIS that our filter initialization was successful.

Listing 16.3 LST16_3.CPP-The GetFilterVersion Function

#define MAJOR_VERSION 1

#define MINOR_VERSION 0

#define MY_FILTER_VERSION MAKELONG(MINOR_VERSION,MAJOR_VERSION)

BOOL WINAPI GetFilterVersion(PHTTP_FILTER_VERSION pVer)

{

pVer->dwFilterVersion = MY_FILTER_VERSION;

strcpy(pVer->lpszFilterDesc,"CustomAuth Filter");

pVer->dwFlags = SF_NOTIFY_ORDER_DEFAULT | SF_NOTIFY_AUTHENTICATION;

return TRUE;

}

Later, we add more tasks to this function, which read the registry and create a cache object. The GetFilterVersion function is a good place to put any other initialization code, since you can tell the Web server whether or not your initialization completed successfully.

Writing the HttpFilterProc Function

The last needed function of a filter is the HttpFilterProc. This function is the entry point for all filter events. We've already told IIS that we're interested in authentication events, so this function is called by IIS whenever a browser's credentials need to be validated.

Listing 16.4 LST16_4.CPP-The HttpFilterProc Function

DWORD WINAPI HttpFilterProc(HTTP_FILTER_CONTEXT* pfc,

DWORD NotificationType,

VOID* pvNotification)

{

// Even though we should only get authentication events,

// you may wish to add your own events later.

switch (NotificationType)

{

case SF_NOTIFY_AUTHENTICATION:

{

// Pull the URL out of the ServerVariables

char pszURL[MAX_URL_SIZE];

DWORD dLen = MAX_URL_SIZE;

pfc->GetServerVariable(pfc,"SCRIPT_NAME",pszURL,&dLen);

// Should we even bother authenticating?

if (CheckFilterList(pszURL))

{

// Cast the filter data to the correct type.

// In this case it's PHTTP_FILTER_AUTHENT

PHTTP_FILTER_AUTHENT pAuthInfo =

(PHTTP_FILTER_AUTHENT)pvNotification;

CString strUser(pAuthInfo->pszUser,

pAuthInfo->cbUserBuff);

CString strPassword(pAuthInfo->pszPassword,

pAuthInfo->cbPasswordBuff);

//

// perform validation here

//

strcpy(pAuthInfo->pszUser, (LPCSTR)strUser);

strcpy(pAuthInfo->pszPassword, (LPCSTR)strPassword);

}

}

break;

} // switch

return SF_STATUS_REQ_NEXT_NOTIFICATION;

}

As a parameter to the HttpFilterProc function, we are given a pointer to HTTP_FILTER_CONTEXT. This structure has a reference to a function named GetServerVariable.

In Listing 16.4, I call this function to get the server variable SCRIPT_NAME, which has the logical path to the document requested by the browser. For example, if the browser requests the URL http://www.leroux.com/secured/nona.htm, the variable SCRIPT_NAME would be /secured/nona.htm.

In the middle of Error! Reference source not found., I call a function named CheckFilterList and pass in as a parameter the value obtained from SCRIPT_NAME. As you'll see in the next section, this function informs the CustomAuth filter that the document should be authenticated using our custom authentication scheme.

If this document should not be authenticated using this new scheme, we'll pass control back to IIS with a return of SF_STATUS_REQ_NEXT_NOTIFICATION and without touching the authentication credentials.

To get the credentials supplied by the browser, we'll have to examine pvNotification. The pvNotification variable passed into HttpFilterProc is a void pointer to a structure that corresponds to the NotificationType.

In this instance, the NotificationType is SF_NOTIFY_AUTHENTICATION. So we should cast pvNotification to the correct data type of PHTTP_FILTER_AUTHENT. The HTTP_FILTER_AUTHENT structure is defined in Listing 16.5.

Listing 16.5 LST16_5.CPP-The HTTP_FILTER_AUTHENT Structure

typedef struct _HTTP_FILTER_AUTHENT

{

CHAR * pszUser; // IN/OUT

DWORD cbUserBuff; // OUT

CHAR * pszPassword; // IN/OUT

DWORD cbPasswordBuff; // OUT

} HTTP_FILTER_AUTHENT, *PHTTP_FILTER_AUTHENT;

I extract the user and password text from this structure and put them into two CStrings. At this point, a database check, explained later, translates the browser user name and password to an NT user name and password.

After this new authentication, the data in the CStrings is copied back into the HTTP_FILTER_AUTHENT structure, where IIS uses the new user name and password as the security context to retrieve the document.


[Tip]


If a filter returns an invalid user name and password to IIS in the HTTP_FILTER_AUTHENT structure, an error is written to the NT application event log.


Filtering on Part of the URL

Since you probably won't want to use CustomAuth.DLL to authenticate every browser request, I add a simple routine to check the requested SCRIPT_NAME against a list of strings. If one of these strings appears in the SCRIPT_NAME, CustomAuth invokes its database authentication process.

Whenever the CustomAuth filter loads, the registry setting HKEY_LOCAL_MACHINE\SOFTWARE\CustomAuth is opened and the URLFilterList value is queried. You need to add this value manually by using the REGEDT32.EXE utility.

This value is of type REG_MULTI_SZ, meaning that it has multiple strings in one registry entry. If the value in any of these strings is in the SCRIPT_NAME server variable, our custom authentication scheme is invoked.

You'll probably add strings like /secured and /private to this registry entry. Then, if a browser requests a URL like http://vicki.shippen.com/secured/special.htm, the CustomAuth filter takes over and does its own authentication.

The first step in providing this type of function is to get a list of "validation" strings from the registry. The LoadFilterParams function, as shown in Listing 16.6, opens the registry, gets the REG_MULTI_SZ value from UrlFilterList, and stores it in the UrlFilterList global variable.

To save time when comparing strings and because IIS is case-insensitive, I immediately convert all strings in UrlFilterList to lowercase.


[Note]


When a REG_MULTI_SZ is queried from the registry and put in a buffer, each string in the list is separated by a null, and the final string in the list is ended with two nulls.


Listing 16.6 LST16_6.CPP-The LoadFilterParams Function

char* UrlFilterList = 0;

void LoadFilterParams()

{

HKEY hKey;

DWORD dwResult;

if (RegCreateKeyEx(HKEY_LOCAL_MACHINE, "SOFTWARE\\CustomAuth", 0, NULL,

REG_OPTION_NON_VOLATILE, KEY_ALL_ACCESS, NULL, &hKey, &dwResult)

== ERROR_SUCCESS)

{

DWORD dwRegLength;

DWORD dwType;

// UrlFilterList - List of strings to check against URL's

// First, get the length of the data so we can allocate space

RegQueryValueEx(hKey,"UrlFilterList", 0, &dwType, NULL, &dwRegLength);

if (dwRegLength > 0 && dwType == REG_MULTI_SZ)

{

// Allocate space

UrlFilterList = new char[dwRegLength];

RegQueryValueEx(hKey, "UrlFilterList", 0, &dwType,

(LPBYTE)UrlFilterList, &dwRegLength);

// convert to lower case

char* pszBigCheck = UrlFilterList;

while (!(*pszBigCheck == 0 && *(pszBigCheck+1) == 0))

{

if (*pszBigCheck >= 'A' && *pszBigCheck <= 'Z')

*pszBigCheck = *pszBigCheck - 'A' + 'a';

pszBigCheck++;

}

}

RegCloseKey(hKey);

}

}

At this point, I add a line to GetFilterVersion to call LoadFilterParams. Therefore, whenever the CustomAuth filter is loaded, the filter immediately queries the registry to get a list of strings to use for SCRIPT_NAME comparison. The next step is to provide the functions to compare the SCRIPT_NAME to the list of strings in UrlFilterList.

The CheckFilterList function, as shown in Listing 16.7, takes a const char* to the SCRIPT_NAME as a parameter and returns a TRUE if any of the strings in UrlFilterList are in it. This function is called in our HttpFilterProc, as shown earlier in Error! Reference source not found.

Listing 16.7 LST16_7.CPP-The CheckFilterList Function

BOOL CheckFilterList(const char* pcszCheckMe)

{

char *pszCheckList = UrlFilterList;

int nCheckListLen = 0;

BOOL bStringFound = FALSE;

while (!bStringFound && (nCheckListLen = strlen(pszCheckList)))

{

if (strstr(pcszCheckMe, pszCheckList) != 0)

bStringFound = TRUE;

pszCheckList += nCheckListLen + 1;

}

return bStringFound;

}

Now that we know when to use the custom authentication scheme, we add code for the user validation against an ODBC data source.

Adding an ODBC Database Check

The idea behind using an ODBC data source to provide our customized authentication scheme is obvious. Instead of maintaining a list of users in a text file or hardcoded into your filter, you can now keep a mapping of user names to NT user accounts in a database.

You may have a list of valid user names in a database already. In that case, you'll just need a stored procedure or static Structured Query Language (SQL) to get that data and pass it back to the CustomAuth filter.

As I wrote CustomAuth, I envisioned a database table called UserTranslation with four columns: Username, Password, TranslatedUsername, and TranslatedPassword. The SQL to get the data from this table would be simple:

SELECT TranslatedUsername, TranslatedPassword

FROM UserTranslation

WHERE Username = <user> and Password = <password>

In this case, the <user> and <password> values would be dynamically inserted into the SQL or, preferably, passed as parameters into a stored procedure. If the user name is on the table and the password is valid, the translated NT user name and password would be passed back to CustomAuth.

If there is no valid entry, no data is passed back. In this case, the translated user and password are blank, simulating an anonymous logon and failing authentication.

I decided to write my database routine using the ODBC API for two reasons. First, nothing beats the speed of straight ODBC function calls. Second, the MFC supplied with Visual C++ 4.1 does not provide thread-safe ODBC classes.

Writing straight ODBC is guaranteed to be safe in a multithreaded environment. Microsoft's Visual C++ 4.2 supposedly uses thread- safe ODBC, but I still find using CDatabase and CRecordset objects a bit awkward.

You can also forget about using the data access object (DAO) classes supplied with MFC. They don't work at all in an ISAPI filter.

Listing 16.8 LST16_8.CPP-The CheckDatabase Function

void DatabaseCheck(const CString& strUser,

const CString& strPassword,

CString& strTranslatedUser,

CString& strTranslatedPassword)

{

HENV henv;

RETCODE retcode = SQLAllocEnv(&henv);

if (retcode == SQL_SUCCESS)

{

HDBC hdbc;

retcode = SQLAllocConnect(henv, &hdbc);

SQLSetConnectOption(hdbc, SQL_LOGIN_TIMEOUT, 5);

retcode = SQLConnect(hdbc,(UCHAR*)(LPCSTR)m_strDataSource, SQL_NTS,

(UCHAR*)(LPCSTR)m_strDataUser, SQL_NTS,

(UCHAR*)(LPCSTR)m_strDataPassword, SQL_NTS);

if (retcode == SQL_SUCCESS || retcode == SQL_SUCCESS_WITH_INFO)

{

HSTMT hstmt;

retcode = SQLAllocStmt(hdbc, &hstmt);

if (retcode == SQL_SUCCESS)

{

CString strSQL;

strSQL.Format("execute TranslateUser '%s','%s'",

strUser, strPassword);

retcode = SQLExecDirect(hstmt, (UCHAR*)(LPCSTR)strSQL,

strSQL.GetLength());

if (retcode == SQL_SUCCESS)

{

SDWORD cbUser(0), cbPassword(0);

SQLBindCol(hstmt, 1, SQL_C_CHAR,

strTranslatedUser.GetBuffer(255), 255,

&cbUser);

SQLBindCol(hstmt, 2, SQL_C_CHAR,

strTranslatedPassword.GetBuffer(255), 255,

&cbPassword);

retcode = SQLFetch(hstmt);

if (retcode == SQL_SUCCESS ||

retcode == SQL_SUCCESS_WITH_INFO)

{

strTranslatedUser.ReleaseBuffer(cbUser); strTranslatedPassword.ReleaseBuffer(

cbPassword);

} else {

strTranslatedUser.ReleaseBuffer(0);

strTranslatedPassword.ReleaseBuffer(0);

}

}

SQLFreeStmt(hstmt, SQL_DROP);

}

SQLDisconnect(hdbc);

}

SQLFreeConnect(hdbc);

}

SQLFreeEnv(henv);

}

Notice that in Listing 16.8, I reference m_strDataSource, m_strDataUser, and m_strDataPassword. For now, you can assume that these three variables are CStrings with global scope. You will soon see, however, that the DatabaseCheck is going to be a member function of a class and that these variables are protected member variables.

In the DatabaseCheck function, I take the user name and password as input, and return the translated user name and password. These translated values are from the SQL stored procedure TranslateUser.

This stored procedure would have an SQL like the one shown in the beginning of this section. If rows are retrieved from this function, they are bound to the strTranslatedUser and strTranslatedPassword parameters, which are returned to the calling function.

You can make the call to DatabaseCheck directly from HttpFilterProc. This, however, is not a great idea. If a browser is mucking about in the /secured section of your site, every document that is requested with the /secured string will make this user translation.

Remember that most browsers cache authenticated credentials. Even though the authentication dialog does not pop up, authentication is still happening behind the scenes.

A browser requesting 20 documents under a secured directory would, in this case, make 20 database hits. A query to a database, especially one needing a connect and a disconnect, is fairly resource-intensive.

One possibility might be to keep the database connection open. But then you must deal with asynchronous queries and additional thread-synchronization problems.

What we really need is a cache of previously translated authentication requests. A successful authentication would add an item to the cache. Future requests for the same credentials would authenticate via the cache instead of the database.

Then we would need a way to kick items out of the cache if they are outdated or the cache is full. In the next section, I show you how to use just such a class.

Building a Cache Class

Before we set out to build a cache that holds a history of previously authenticated users, we should probably step back and do a little object-oriented design. So far, our custom authentication scheme has fit into a few small C functions. What would be nice is have a class that would do database validation and caching, and provide a simple interface to call from our HttpFilterProc function.

I came up with a simple two-class approach. The first class is a cache item and the second is the cache class. The cache item is responsible for storing the user name, password, translated user name, and translated password of a previously authenticated request.

The cache item would also be responsible for keeping track of how long the request has existed. Since items should not stay in the cache forever, we must provide a mechanism for their expiration. Error! Reference source not found shows the declaration for the CacheItem class.

Listing 16.9 LST16_9.H-The CacheItem Class

class CacheItem {

public:

CacheItem(CString& strUser, CString& strPassword,

CString& strTranslatedUser, CString& strTranslatedPassword,

const int nExpireMins);

~CacheItem() {};

CString& GetUser() { return m_strUser; }

CString& GetPassword() { return m_strPassword; }

CString& GetTranslatedUser() { return m_strTranslatedUser; }

CString& GetTranslatedPassword() { return m_strTranslatedPassword;}

COleDateTime& GetTimeAdded() { return m_dtTimeAdded; }

COleDateTime& GetTimeExpired() { return m_dtTimeExpired; }

BOOL IsExpired();

private:

CString m_strUser;

CString m_strPassword;

CString m_strTranslatedUser;

CString m_strTranslatedPassword;

COleDateTime m_dtTimeAdded;

COleDateTime m_dtTimeExpired;

BOOL m_bDoNotExpire;

};

There's really no rocket science behind the CacheItem. It keeps track of the time it was added and the time it should expire. In its constructor, we tell the CacheItem how long it will take before it should be considered expired. The IsExpired function returns whether or not the CacheItem should be considered valid.

Our next step is to build a container to hold CacheItems. An MFC Map class would work well in this situation. We could map the untranslated user name to a pointer to a CacheItem.

One way to do this would be to use a CMapStringToPtr map. However, a better way would be to derive our own map class from a templated map class like this:

typedef CMap<CString, LPCSTR, CacheItem*, CacheItem*> THE_CACHE;

Now we can derive from THE_CACHE and give ourselves a nice, type-safe CString to CacheItem map. The declaration for our new cache class is simple, as shown in Listing 16.10.

Listing 16.10 LST16_10.H-The CCache Class

class CCache : public THE_CACHE

{

public:

CCache(int nMaxCacheSize, int nKeepCacheMinutes,

CString strDataSource, CString strDataUser,

CString strDataPassword);

~CCache() {};

void TranslateUser(CString& strUser, CString& strPassword);

protected:

void DeleteOldest();

void DatabaseCheck(const CString& strUser, const CString& strPassword,

CString& strTranslatedUser,

CString& strTranslatedPassword);

int m_nMaxCacheSize;

int m_nKeepCacheMinutes;

CString m_strDataSource;

CString m_strDataUser;

CString m_strDataPassword;

CCriticalSection m_csMap;

};

The only section of this class that may need a bit of explaining is the CCriticalSection member variable m_csMap. Remember that since there will be only one cache for our filter, we'll need to protect all data that will be stored in this map from the feet of stomping threads.

Using the MFC CCriticalSection class is a simple way to ensure that only one thread reads and writes to the cache at a time. You'll soon see how we use the CSingleLock class with the CCricitalSection class to provide thread synchronization.

The only public interface to the CCache class is through the TranslateUser member function. This function takes references to two CStrings as parameters: the user name and the password to be validated.

The TranslateUser function translates these credentials into an NT user name and a password. First, the cache (map) is checked to see if this user has been authenticated recently.

If this user name is in the cache but is no longer valid because it has reached its expiration time, the CacheItem is removed from the CCache. If the user name has not expired, the password is compared to the password in the cache. If the password matches, the translated user name and password are taken from the cache and passed back into the return parameters.

Listing 16.11 shows how the CCache::TranslateUser function works.

Listing 16.11 LST16_11.CPP-The CCache::TranslateUser Function

void CCache::TranslateUser(CString& strCheckUser, CString& strCheckPassword)

{

if (strCheckUser != "") // don't bother with the empty guys

{

CacheItem* pCItem;

BOOL bFoundInCache(FALSE);

CString strTranslatedUser("");

CString strTranslatedPassword("");

// First, check to see if it's in the cache yet...

if (Lookup(strCheckUser, pCItem))

{

// User found in cache

if (!pCItem->IsExpired())

{

bFoundInCache = TRUE;

// If password is not ok, they shouldn't be allowed in

if (strCheckPassword == pCItem->GetPassword())

{

// User entered ok password

strTranslatedUser = pCItem->GetTranslatedUser();

strTranslatedPassword =

pCItem->GetTranslatedPassword();

}

} else {

// expired! remove from cache and re-check user

RemoveKey(strCheckUser);

delete pCItem;

}

}

if (!bFoundInCache) // then we must perform a database check..

{

DatabaseCheck(strCheckUser, strCheckPassword,

strTranslatedUser, strTranslatedPassword);

if (strTranslatedUser != "")

{

// if cache is full, delete the oldest item and clean up

CSingleLock lockMap(&m_csMap, TRUE);

if (GetCount() >= m_nMaxCacheSize)

DeleteOldest();

// Add item into cache

pCItem = new CacheItem(strCheckUser, strCheckPassword,

strTranslatedUser, strTranslatedPassword,

m_nKeepCacheMinutes);

SetAt(strCheckUser, pCItem);

lockMap.Unlock();

}

}

// Return translated stuff back to the user..

strCheckUser = strTranslatedUser;

strCheckPassword = strTranslatedPassword;

}

}

If the user name cannot be found in the cache (or the password entered is different from the one found in the cache), the database must be checked to validate the user name. You can see in Listing 16.11 that the DatabaseCheck function is called. I've basically taken the code from Listing 16.8 and turned it into a protected member function of CCache.

If the DatabaseCheck function returns a blank as the translated user name, I assume that the credentials did not pass validation and this item should not be added to the cache. On the other hand, if the DatabaseCheck returns a translated user name, a CacheItem object is created on the heap and added to the cache.

The only tricky part here is if the CCache is full. Listing 16.12 shows what happens if the cache is full when we need to add a new CacheItem.

Listing 16.12 LST16_12.CPP-The CCache::DeleteOldest Function

void CCache::DeleteOldest()

{

POSITION pos;

CString key;

CacheItem* pCacheItem = NULL;

CacheItem* pOldestCacheItem = NULL;

BOOL bAlreadyDeletedOne = FALSE;

for (pos = GetStartPosition(); pos != NULL; )

{

GetNextAssoc(pos, key, pCacheItem);

if (pCacheItem->IsExpired())

{

// If expired, delete it...

bAlreadyDeletedOne = TRUE;

RemoveKey(pCacheItem->GetUser());

delete pCacheItem;

}

if (!bAlreadyDeletedOne)

// If we deleted one already, we don't need to go through this

if (pOldestCacheItem == NULL)

pOldestCacheItem = pCacheItem;

else

if (pCacheItem->GetTimeAdded() <

pOldestCacheItem->GetTimeAdded())

pOldestCacheItem = pCacheItem;

}

if (!bAlreadyDeletedOne && (pOldestCacheItem != NULL))

{

RemoveKey(pCacheItem->GetUser());

delete pOldestCacheItem;

}

}

The DeleteOldest function does more than delete the oldest item in the cache. Since it already searches the entire cache looking for the oldest CacheItem, it also deletes every CacheItem it finds that has expired. If an expired CacheItem is found, we are guaranteed space to add our new entry into the cache.

Let me take this moment to talk about thread synchronization. Notice how the TranslateUser CCache member function uses the CSingleLock object. Since it is likely that multiple threads will be executing through the CustomAuth filter concurrently, we need to make sure that only one thread is manipulating the cache at a time.

We also need to make sure that if a CacheItem is deleted because the cache is full, other threads wait until the new CacheItem has been added before doing any operations on the cache. The m_csMap CCriticalSection member variable of CCache is passed into the CSingleLock constructor at the start of all cache operations.

At that point, the CSingleLock has locked the CCricitalSection object. No other thread can enter this section of code until the CSingleLock has called its Unlock member function.

In fact, a thread waits indefinitely until the CSingleLock has unlocked the CCriticalSection. After the cache operations have completed, the lock on the critical section is released.

As you may have noticed, the constructor to CCache takes five parameters. The first two, nMaxCacheSize and nKeepCacheMinutes, define our cache behavior. The nMaxCahceSize parameter specifies the maximum number of CacheItems that can exist before we should start purging the cache.

The nKeepCacheMinutes parameter specifies the number of minutes that a CacheItem should be considered "valid" (that is, before it expires). A CacheItem that has been around longer than nKeepCacheMinutes can be deleted.

The last three parameters passed into CCache are ODBC connection parameters. The first, strDataSource is the ODBC data source that will be opened.

The second and third, strDataUser and strDataPassword, are the credentials used to log in to the ODBC data source. All of these parameters will be stored in the registry and passed to the cache on instantiation.


[Note]


All ODBC data sources that will be used by ISAPI filters and extensions must be defined as a "system" data source. This has the effect of making the ODBC data source visible to all users and system processes. You can do this by choosing the System DSN… button from the ODBC Administrator Control Panel applet.


Our CCache is instantiated in the GetFilterVersion function. In our call to LoadFilterParams, we get the CCache constructor parameters from the registry and put them into global variables.

Then we create a CCache object on the heap with these parameters. Last, we put a call to the CCache::TranslateUser function in the HttpFilterProc. Our Cache is now connected to the ISAPI authentication filter.

Completing CustomAuth Application

That takes care of all of the major pieces of our CustomAuth filter DLL. We are left with gluing all of pieces together and compiling our filter into a DLL. A full listing of the CustomAuth filter application can be found on the CD.

I have taken a few shortcuts here and there when writing the CustomAuth filter. You may want to add the following new features to make CustomAuth a more durable application:

In the next section, you learn how to process the ISAPI log event that gives your Web site an audit log with custom information.

Building a Custom Logging Filter

As you may know, three styles of logging are built into Microsoft's IIS version 2.0. The first is standard file logging. This means that log data is saved to a file, with all fields separated by commas.

The second format for logging is also to a file but in the NCSA standard format. The third style of logging is to an ODBC data source table.

You can use the Logging property sheet of the Microsoft Internet Service Manager, as shown in Figure 16.4, to change the active logging style.

Fig. 16.4

The Logging property sheet of Microsoft's Internet Service Manager.

If you select standard file logging or ODBC table logging, the data recorded is exactly the same. Table 16.1 lists the fields, in the order they occur, in the standard log file and the ODBC log table.

The NCSA-style logging has a subset of the information listed in Table 16.1 but in a format that many log-analysis programs can read. Microsoft, however, has supplied CONVLOG.EXE, a log- conversion program with IIS that translates a Microsoft-style log file into an NCSA-style log file.


[Note]


The filter we are building in this chapter adds columns to the Microsoft-style log file. As a result, the CONVLOG.EXE utility supplied with IIS that converts Microsoft-style logs to NCSA-style logs won't work.


To convert the new-style log file to NCSA style, you first strip out the new fields and then run the resulting file through the CONVLOG.EXE utility. Of course, the other solution is just to write your own CONVLOG application.


Table 16.1 The Log File Format-Standard Log File and ODBC Log Table

Name

Description

Client host

IP address of the client

User name

User name of the client (post-authentication)

Log date

Date the log record was written

Log time

Time the log record was written

Service

Service (W3SVC, MSFTPSVC, or GOPHERSVC)

Machine

WINS computer name of the server

Server IP

Server's IP address

Processing time

Elapsed time, in milliseconds, to send the document

Bytes received

Number of bytes received from the client

Bytes sent

Number of bytes sent from the server to the client

HTTP return status

Return status of the service

Win32 status

Win32 error code

Operation

Operation requested (POST or GET)

Target

Document and path requested

Parameters

Any parameters sent after the target

As you will soon see, you can replace the contents of some of the items in the log with other pieces of information (usually obtained via queries to the Web server). Unfortunately, the ISAPI hooks into the logging event are not flexible, and adding new data to the logs is not pretty.

The ISAPI filter I present, CustomLog, does a good job of "pseudo-adding" columns to the standard log file without adding an entirely new logging process.

Understanding How CustomLog Works

The ISAPI filter event, SF_NOTIFY_LOG, occurs immediately before the Web server writes its information to the logs (no matter what style of logging you have enabled). When your ISAPI filter registers to receive this event and a browser requests a document from your Web site, your filter is given the HTTP_FILTER_LOG data structure, which looks like Listing 16.13.

Listing 16.13 LST16_13.H-The HTTP_FILTER_LOG Structure

typedef struct _HTTP_FILTER_LOG

{

const CHAR * pszClientHostName; // Client's host name

const CHAR * pszClientUserName; // Client's user name

const CHAR * pszServerName; // Name of the server the client connected to

const CHAR * pszOperation; // HTTP command

const CHAR * pszTarget; // Target of HTTP command

const CHAR * pszParameters; // Parameters passed to HTTP command

DWORD dwHttpStatus; // HTTP Return status

DWORD dwWin32Status; // Win32 Error code

} HTTP_FILTER_LOG, *PHTTP_FILTER_LOG;

You can't copy data on top of any of the member variables (hence the const before the CHAR) in the HTTP_FILTER_LOG structure. But you can replace the pointer in the structure with a pointer to memory that you have allocated.

At first it may look ridiculous. Why would you want to replace any of these columns with your own data? The elements supplied in this structure are all important pieces of information that you want to keep in the logs.

Well, the answer is that you wouldn't. Believe it or not, we are going to squeeze four fields into one variable. Sound a bit messy? It is. Let me explain.

First off, if you are using an ODBC data source or NCSA-style logging as the format for your logs, you are not going to like this solution. It is impossible with version 2.0 of Microsoft's IIS to add additional columns in a log table without using a new logging process from scratch (which I wouldn't recommend).

The CustomLog solution presented in this chapter works only with the standard log file format. Since the standard log file format consists of comma-delimited log elements, we are going to replace the first field, the client's IP address (pszClientHostName) with four fields that were previously unlogged (three were previously unlogged-the fourth is the client's IP address that will be stuck at the end of the string).

We do this by comma-delimiting the four fields during the SF_NOTIFY_LOG event-that is, before IIS puts them in the logs. Here are three new fields that we are adding:

Before you start writing code for the CustomLog filter, you need to set up a new C++ project. As explained in the CustomAuth filter, you need to prototype your two filter entry point functions and create a .DEF file to export these functions from your DLL.

As with the CustomAuth filter, I do not use the ISAPI Extension Wizard or any of the MFC ISAPI classes. By contrast, I use no MFC classes at all. This is probably a good idea, since you'll want to streamline this code as much as possible. Let's begin.

Allowing Only the Standard Log File

Since CustomLog only works when you log to a standard log file, there is no need for our filter to load if NCSA style logging is active, ODBC style logging is active, or no logging is active. All information about Microsoft's IIS is stored in the registry under the following key:

HKEY_LOCAL_MACHINE\CurrentControlSet\Services\W3SVC\Parameters

Two entries under this key tell us the type of logging selected: LogType and LogFileFormat. Table 16.2 illustrates the type of logging that corresponds to values in each of these registry entries.

Table 16.2 Possible Registry Entries for LogType and LogFileFormat

LogType

LogFileFormat

Description

0

0

Logging has been disabled

1

0

Logging to file, standard format

1

3

Logging to file, NCSA format

2

0

Logging to ODBC

We'll start by writing a function that opens the registry, queries the LogType and LogFileFormat entries, and returns TRUE if the Web server is indeed using standard format logging. The code to perform this procedure is shown in Listing 16.14.

Listing 16.14 LST16_14.CPP-The IsStandardLogFile Function

BOOL IsStandardLogFile()

{

HKEY hKey;

DWORD dwResult;

DWORD dwLogFileFormat;

DWORD dwLogType;

if (RegCreateKeyEx(HKEY_LOCAL_MACHINE,

"SYSTEM\\CurrentControlSet\\Services\\W3SVC\\Parameters",

0, NULL, REG_OPTION_NON_VOLATILE, KEY_READ, NULL,

&hKey, &dwResult) == ERROR_SUCCESS)

{

DWORD dwRegLength;

DWORD dwType;

RegQueryValueEx(hKey, "LogFileFormat", 0, &dwType,

(LPBYTE)&dwLogFileFormat, &dwRegLength);

RegQueryValueEx(hKey, "LogType", 0, &dwType, (LPBYTE)&dwLogType,

&dwRegLength);

RegCloseKey(hKey);

}

return (dwLogFileFormat == 0 && dwLogType == 1);

}

This procedure is straightforward. If the registry key is opened successfully, the values for the two registry entries are queried. If the LogType is 1 and the LogFileFormat is 0, this function returns TRUE. Any other values for these entries return FALSE. As you see in the next section, this function is called by the GetFilterVersion ISAPI function.

Writing the GetFilterVersion Function

As with all ISAPI filters, the CustomLog filter must have a GetFilterVersion function. The two events we are interested in trapping are the SF_NOTIFY_LOG event and the SF_NOTIFY_END_OF_NET_SESSION event.

The first, SF_NOTIFY_LOG, is the event in which the logic to change the log data occurs. The second, SF_NOTIFY_END_OF_NET_SESSION, is used to clean up memory that was allocated during the log event. I explain more about this event later in the chapter.

Listing 16.15 shows how the GetFilterVersion Function works.

Listing 16.15 LST16_15.CPP-The GetFilterVersion Function

BOOL WINAPI GetFilterVersion(PHTTP_FILTER_VERSION pVer)

{

pVer->dwFilterVersion = MY_FILTER_VERSION;

strcpy(pVer->lpszFilterDesc,"CustomLog Filter");

pVer->dwFlags = SF_NOTIFY_ORDER_DEFAULT | SF_NOTIFY_LOG |

SF_NOTIFY_END_OF_NET_SESSION;

return IsStandardLogFile();

}

Notice the call to the IsStandardLogFile function in the last line of our GetFilterVersion function in Error! Reference source not found. If we are indeed logging to a standard format file, we tell the Web server that our filter should be loaded. Returning FALSE from GetFilterVersion tells the Web server that there is a problem and our filter should not be loaded.

Writing the HttpFilterProc Function

Logically, the first step in building our HttpFilterProc is to determine where we get the data for our new logging columns. Microsoft's IIS takes all headers supplied by the browser and turns them into server variables that can be queried using the filter context's GetServerVariable function.

IIS just slaps an HTTP_prefix in front of the header name. Therefore, the User-Agent header becomes the HTTP_USER_AGENT server variable and the Referer header becomes the HTTP_REFERER server variable. The unmapped remote user variable is aptly named UNMAPPED_REMOTE_USER. You'll have to hunt around in the documentation to find that one.

We'll copy the result of each of these variables into a temporary buffer. For instance, the Listing 16.16 is used to get the HTTP_USER_AGENT server variable. This data is then put in the pszUserAgent buffer and the length is put in the cbUserAgentLen DWORD.

Listing 16.16 LST16_16.CPP-Getting the HTTP_USER_AGENT

char pszUserAgent[MAX_USER_AGENT] = {0};

DWORD cbUserAgentLen(MAX_USER_AGENT);

if (!pfc->GetServerVariable(pfc,"HTTP_USER_AGENT",

pszUserAgent,&cbUserAgentLen))

if (GetLastError() == ERROR_INVALID_INDEX)

{

pszUserAgent[0] = '-';

pszUserAgent[1] = 0;

cbUserAgentLen = 1;

}

Notice that if the GetServerVariable function fails and the last error is ERROR_INVALID_INDEX, we know that the Referer header was not supplied with the request. In this case, we put a "-" character in the buffer to differentiate from a Referer header that was supplied without data.

Also remember that since the UNMAPPED_REMOTE_USER server variable does not come from the header (well, it really does but it's encoded and handled differently by IIS), it will always exist.

The HTTP_FILTER_LOG member that we commandeer is the pszClientHostName pointer. Since we are not allowed to copy our own strings into any of the HTTP_FILTER_LOG pointer variables themselves, we allocate another buffer big enough to hold our three new variables, the pszClientHostName, and our comma separators, spaces, and null terminator.

The ISAPI documentation says that if we replace the pointer to a data member in HTTP_FILTER_LOG, the data pointed to by the new member must be valid until either (a) the next SF_NOTIFY_LOG event or until (b) the SF_NOTIFY_END_OF_NET_SESSION event. To satisfy this need, we allocate a buffer on the heap using the new[] operator.


[Note]


The ISAPI HTTP_FILTER_CONTEXT structure provides a function named AllocMem. This function allocates memory on the heap that is guaranteed to be freed at the end of a client's session.


If we were to use this function, we would not need to keep track of our buffers and we would not need to trap the SF_NOTIFY_END_OF_NET_SESSION event to free our memory. However, I am not partial to other processes' memory-allocation functions and I would rather use the C++ new[] and delete[] operators to allocate and free my buffer space.


To really boost performance, Microsoft's documentation suggests starting your filter with a pool of buffers, and dynamically enlarging and shrinking this pool according to Web site activity. This lessens the time wasted by allocating and freeing buffer space.


We can determine the size of our buffer by adding the length of each of our four strings and then adding 7 to account for the commas and the null terminator.

char* pszNewData = new char[cbUserAgentLen +

cbRefererLen +

cbUnmappedUserLen +

strlen(pLogInfo->pszClientHostName) +

7]; // for the commas, spaces and null

Next, we copy our four strings into the new buffer.

sprintf(pszNewData, "%s, %s, %s, %s", pszReferer, pszUserAgent,

pszUnmappedUser, pLogInfo->pszClientHostName);

And then we put the pointer to our buffer in the HTTP_FILTER_LOG structure.

pLogInfo->pszClientHostName = pszNewData;

And that's it, right? Not quite. When are we going to delete the pszNewData buffer? And more important, since the ISAPI HttpHeaderProc can be called many times for each client request, how are we going to keep track of our new buffer? It's actually quite easy.

The HTTP_FILTER_CONTEXT structure has a member variable pFilterContext. This pointer is given to the filter for just such an occasion. At the start of a client session, the pFilterContext is guaranteed to be null. It is also guaranteed to be unchanged for each event in a client's session.

Therefore, if we put a pointer in pFilterContext in the SF_NOTIFY_LOG event, the pFilterContext should have the same pointer for the same client session's SF_NOTIFY_END_OF_NET_SESSION event. If that's the case, we just put a delete[] in the SF_NOTIFY_END_OF_NET_SESSION event and that's it, right? Again, not quite.

The SF_NOTIFY_LOG event can't called multiple times before the SF_NOTIFY_END_OF_NET_SESSION event. Therefore, before we do any writing to pFilterContext in the SF_NOTIFY_LOG event, we should make sure that pFilterContext does not already have a valid pointer. If it does, we should delete[] it.

Our completed HttpFilterProc appears in Listing 16.17. We should now be ready to compile our CustomLog filter.

Listing 16.17 LST16_17.CPP-The HttpFilterProc Function

DWORD WINAPI HttpFilterProc(HTTP_FILTER_CONTEXT* pfc,

DWORD NotificationType,

VOID* pvNotification)

{

switch (NotificationType)

{

case SF_NOTIFY_LOG:

{

// Cast the filter data to the correct type.

// In this case, it's PHTTP_FILTER_LOG

PHTTP_FILTER_LOG pLogInfo =

(PHTTP_FILTER_LOG)pvNotification;

// Delete any previous items in the filter context

// if they've stuck around

if (pfc->pFilterContext)

delete[] pfc->pFilterContext;

// HTTP_USER_AGENT

// Set to '-' if it does not exist

char pszUserAgent[MAX_USER_AGENT] = {0};

DWORD cbUserAgentLen(MAX_USER_AGENT);

if (!pfc->GetServerVariable(pfc,"HTTP_USER_AGENT",

pszUserAgent,&cbUserAgentLen))

if (GetLastError() == ERROR_INVALID_INDEX)

{

pszUserAgent[0] = '-';

pszUserAgent[1] = 0;

cbUserAgentLen = 1;

}

// HTTP_REFERER

// Set to '-' if it does not exist

char pszReferer[MAX_REFERER] = {0};

DWORD cbRefererLen(MAX_REFERER);

if (!pfc->GetServerVariable(pfc,"HTTP_REFERER",

pszReferer,&cbRefererLen))

if (GetLastError() == ERROR_INVALID_INDEX)

{

pszReferer[0] = '-';

pszReferer[1] = 0;

cbRefererLen = 1;

}

// UNMAPPED_REMOTE_USER

// (this one will always exist)

char pszUnmappedUser[MAX_UNMAPPED] = {0};

DWORD cbUnmappedUserLen(MAX_UNMAPPED);

pfc->GetServerVariable(pfc,"UNMAPPED_REMOTE_USER",

pszUnmappedUser,&cbUnmappedUserLen);

if (cbUnmappedUserLen == 0)

{

pszUnmappedUser[0] = '-';

pszUnmappedUser[1] = 0;

cbUnmappedUserLen = 1;

}

// Build the big string

char* pszNewData = new char[cbUserAgentLen +

cbRefererLen +

cbUnmappedUserLen +

strlen(pLogInfo->pszClientHostName) +

7]; // for the commas, spaces and null

;

// tack them on to the beginning of the log record

sprintf(pszNewData, "%s, %s, %s, %s", pszReferer, pszUserAgent,

pszUnmappedUser, pLogInfo->pszClientHostName);

pLogInfo->pszClientHostName = pszNewData;

pfc->pFilterContext = pszNewData;

}

break;

case SF_NOTIFY_END_OF_NET_SESSION:

{

// delete the string if it's valid

if (pfc->pFilterContext)

delete[] pfc->pFilterContext;

}

break;

} // switch

return SF_STATUS_REQ_NEXT_NOTIFICATION;

}

After you install the CustomLog filter and activate standard logging, you should see the three new columns prepended to each line of the log file. As mentioned before, you may need to write a utility to convert this file to NCSA file format to use tools that provide Web site statistics.

I think a better solution would be to insert the log files into a database. At that point, your database can compile statistics, or you can export the log table from your database into the NCSA formatted file.

Completing the CustomLog Filter

Now that our CustomLog filter is complete, here are a few ideas that you an use to further extend this filter.

From Here...

As shown in this chapter, providing additional features to your Web site using ISAPI filters isn't hard. In fact, you can use the CustomAuth and CustomLog filters as a foundation for building additional functionality to your Web site. These two examples also touch on important concepts, such as thread safety and memory allocation, that you need to be familiar with when building your own custom filters.

While you are building these filters, check out the following chapters for reference and guidance:


© 1996, QUE Corporation, an imprint of Macmillan Publishing USA, a Simon and Schuster Company.