1. 程式人生 > >細說ASP.NET Forms身份認證

細說ASP.NET Forms身份認證

使用者登入是個很常見的業務需求,在ASP.NET中,這個過程被稱為身份認證。 由於很常見,因此,我認為把這塊內容整理出來,與大家分享應該是件有意義的事。

在開發ASP.NET專案中,我們最常用的是Forms認證,也叫【表單認證】。 這種認證方式既可以用於區域網環境,也可用於網際網路環境,因此,它有著非常廣泛的使用。 這篇部落格主要討論的話題是:ASP.NET Forms 身份認證。

有一點我要申明一下:在這篇部落格中,不會涉及ASP.NET的登入系列控制元件以及membership的相關話題, 我只想用比較原始的方式來說明在ASP.NET中是如何實現身份認證的過程。

回到頂部

ASP.NET身份認證基礎

在開始今天的部落格之前,我想有二個最基礎的問題首先要明確:
1. 如何判斷當前請求是一個已登入使用者發起的?
2. 如何獲取當前登入使用者的登入名?

在標準的ASP.NET身份認證方式中,上面二個問題的答案是:
1. 如果Request.IsAuthenticated為true,則表示是一個已登入使用者。
2. 如果是一個已登入使用者,訪問HttpContext.User.Identity.Name可獲取登入名(都是例項屬性)。

接下來,本文將會圍繞上面二個問題展開,請繼續閱讀。

回到頂部

ASP.NET身份認證過程

在ASP.NET中,整個身份認證的過程其實可分為二個階段:認證與授權。
1. 認證階段:識別當前請求的使用者是不是一個可識別(的已登入)使用者。
2. 授權階段:是否允許當前請求訪問指定的資源。

這二個階段在ASP.NET管線中用AuthenticateRequest和AuthorizeRequest事件來表示。
在認證階段,ASP.NET會檢查當前請求,根據web.config設定的認證方式,嘗試構造HttpContext.User物件供我們在後續的處理中使用。 在授權階段,會檢查當前請求所訪問的資源是否允許訪問,因為有些受保護的頁面資源可能要求特定的使用者或者使用者組才能訪問。 所以,即使是一個已登入使用者,也有可能會不能訪問某些頁面。 當發現使用者不能訪問某個頁面資源時,ASP.NET會將請求重定向到登入頁面。

受保護的頁面與登入頁面我們都可以在web.config中指定,具體方法可參考後文。

在ASP.NET中,Forms認證是由FormsAuthenticationModule實現的,URL的授權檢查是由UrlAuthorizationModule實現的。

回到頂部

如何實現登入與登出

前面我介紹了可以使用Request.IsAuthenticated來判斷當前使用者是不是一個已登入使用者,那麼這一過程又是如何實現的呢?

為了回答這個問題,我準備了一個簡單的示例頁面,程式碼如下:

<fieldset><legend>使用者狀態</legend><form action="<%= Request.RawUrl %>" method="post">
    <% if( Request.IsAuthenticated ) { %>
        當前使用者已登入,登入名:<%= Context.User.Identity.Name.HtmlEncode() %> <br />            
        <input type="submit" name="Logon" value="退出" />
    <% } else { %>
        <b>當前使用者還未登入。</b>
    <% } %>            
</form></fieldset>

頁面顯示效果如下:

根據前面的程式碼,我想現在能看到這個頁面顯示也是正確的,是的,我目前還沒有登入(根本還沒有實現這個功能)

下面我再加點程式碼來實現使用者登入。頁面程式碼:

<fieldset><legend>普通登入</legend><form action="<%= Request.RawUrl %>" method="post">
    登入名:<input type="text" name="loginName" style="width: 200px" value="Fish" />
    <input type="submit" name="NormalLogin" value="登入" />
</form></fieldset>

現在頁面的顯示效果:

登入與退出登入的實現程式碼:

public void Logon()
{
    FormsAuthentication.SignOut();
}

public void NormalLogin()
{
    // -----------------------------------------------------------------
    // 注意:演示程式碼為了簡單,這裡不檢查使用者名稱與密碼是否正確。
    // -----------------------------------------------------------------

    string loginName = Request.Form["loginName"];
    if( string.IsNullOrEmpty(loginName) )
        return;
    
    FormsAuthentication.SetAuthCookie(loginName, true);

    TryRedirect();
}

現在,我可試一下登入功能。點選登入按鈕後,頁面的顯示效果如下:

從圖片的顯示可以看出,我前面寫的NormalLogin()方法確實可以實現使用者登入。
當然了,我也可以在此時點選退出按鈕,那麼就回到了圖片2的顯示。

寫到這裡,我想有必要再來總結一下在ASP.NET中實現登入與登出的方法:
1. 登入:呼叫FormsAuthentication.SetAuthCookie()方法,傳遞一個登入名即可。
2. 登出:呼叫FormsAuthentication.SignOut()方法。

回到頂部

保護受限制的頁面

在一個ASP.NET網站中,有些頁面會允許所有使用者訪問,包括一些未登入使用者,但有些頁面則必須是已登入使用者才能訪問, 還有一些頁面可能會要求特定的使用者或者使用者組的成員才能訪問。 這類頁面因此也可稱為【受限頁面】,它們一般代表著比較重要的頁面,包含一些重要的操作或功能。

為了保護受限制的頁面的訪問,ASP.NET提供了一種簡單的方式: 可以在web.config中指定受限資源允許哪些使用者或者使用者組(角色)的訪問,也可以設定為禁止訪問。

比如,網站有一個頁面:MyInfo.aspx,它要求訪問這個頁面的訪問者必須是一個已登入使用者,那麼可以在web.config中這樣配置:

<location path="MyInfo.aspx">
    <system.web>
        <authorization>
            <deny users="?"/>
        </authorization>
    </system.web>
</location>

為了方便,我可能會將一些管理相關的多個頁面放在Admin目錄中,顯然這些頁面只允許Admin使用者組的成員才可以訪問。 對於這種情況,我們可以直接針對一個目錄設定訪問規則:

<location path="Admin">
    <system.web>
        <authorization>
            <allow roles="Admin"/>
            <deny users="*"/>
        </authorization>
    </system.web>
</location>

這樣就不必一個一個頁面單獨設定了,還可以在目錄中建立一個web.config來指定目錄的訪問規則,請參考後面的示例。

在前面的示例中,有一點要特別注意的是:
1. allow和deny之間的順序一定不能寫錯了,UrlAuthorizationModule將按這個順序依次判斷。
2. 如果某個資源只允許某類使用者訪問,那麼最後的一條規則一定是 <deny users="*" />

在allow和deny的配置中,我們可以在一條規則中指定多個使用者:
1. 使用users屬性,值為逗號分隔的使用者名稱列表。
2. 使用roles屬性,值為逗號分隔的角色列表。
3. 問號 (?) 表示匿名使用者。
4. 星號 (*) 表示所有使用者。

回到頂部

登入頁不能正常顯示的問題

有時候,我們可能要開發一個內部使用的網站程式,這類網站程式要求 禁止匿名使用者的訪問,即:所有使用者必須先登入才能訪問。 因此,我們通常會在網站根目錄下的web.config中這樣設定:

<authorization>
    <deny users="?"/>
</authorization>

對於我們的示例,我們也可以這樣設定。此時在瀏覽器開啟頁面時,呈現效果如下:

從圖片中可以看出:頁面的樣式顯示不正確,最下邊還多出了一行文字。

這個頁面的完整程式碼是這樣的(它引用了一個CSS檔案和一個JS檔案): 

頁面最後一行文字平時不顯示是因為JScript.js中有以下程式碼:

document.getElementById("hideText").setAttribute("style", "display: none");

這段JS程式碼能做什麼,我想就不用再解釋了。
雖然這段JS程式碼沒什麼價值,但我主要是想演示在登入頁面中引用JS的場景。

根據前面圖片,我們可以猜測到:應該是CSS和JS檔案沒有正確載入造成的。
為了確認就是這樣原因,我們可以開啟FireBug再來看一下頁面載入情況:

根據FireBug提供的線索我們可以分析出,頁面在訪問CSS, JS檔案時,其實是被重定向到登入頁面了,因此獲得的結果肯定也是無意義的, 所以就造成了登入頁的顯示不正確。

還記得前所說的【授權】嗎?
是的,現在就是由於我們在web.config中設定了不允許匿名使用者訪問,因此,所有的資源也就不允許匿名使用者訪問了, 包括登入頁所引用的CSS, JS檔案。當授權檢查失敗時,請求會被重定向到登入頁面, 所以,登入頁本身所引用的CSS, JS檔案最後得到的響應內容其實是登入頁的HTML程式碼, 最終導致它們不能發揮作用,表現為登入頁的樣式顯示不正確,以及引用的JS檔案也不起作用。

不過,有一點比較奇怪:為什麼訪問登入頁面時,沒有發生重定向呢?
原因是這樣的:在ASP.NET內部,當發現是在訪問登入面時,會設定HttpContext.SkipAuthorization = true (其實是一個內部呼叫), 這樣的設定會告訴後面的授權檢查模組:跳過這次請求的授權檢查。 因此,登入頁總是允許所有使用者訪問,但是CSS檔案以及JS檔案是在另外的請求中發生的,那些請求並不會要跳過授權模組的檢查。

為了解決登入頁不能正確顯示的問題,我們可以這樣處理:
1. 在網站根目錄中的web.config中設定登入頁所引用的JS, CSS檔案都允許匿名訪問。
2. 也可以直接針對JS, CSS目錄設定為允許匿名使用者訪問。
3. 還可以在CSS, JS目錄中建立一個web.config檔案來配置對應目錄的授權規則。可參考以下web.config檔案:

<?xml version="1.0"?>
<configuration>
    <system.web>
        <authorization>
            <allow users="*"/>
        </authorization>
    </system.web>
</configuration>

第三種做法可以不修改網站根目錄下的web.config檔案。

注意:在IIS中看到的情況就和在Visual Studio中看到的結果就不一樣了。 因為,像js, css, image這類檔案屬於靜態資原始檔,IIS能直接處理,不需要交給ASP.NET來響應,因此就不會發生授權檢查失敗, 所以,如果這類網站部署在IIS中,看到的結果又是正常的。

回到頂部

認識Forms身份認證

前面我演示瞭如何用程式碼實現登入與登出的過程,下面再來看一下登入時,ASP.NET到底做了些什麼事情, 它是如何知道當前請求是一個已登入使用者的?

在繼續探索這個問題前,我想有必要來了解一下HTTP協議的一些特點。
HTTP是一個無狀態的協議,無狀態的意思可以理解為: WEB伺服器在處理所有傳入請求時,根本就不知道某個請求是否是一個使用者的第一次請求與後續請求,或者是另一個使用者的請求。 WEB伺服器每次在處理請求時,都會按照使用者所訪問的資源所對應的處理程式碼,從頭到尾執行一遍,然後輸出響應內容, WEB伺服器根本不會記住已處理了哪些使用者的請求,因此,我們通常說HTTP協議是無狀態的。

雖然HTTP協議與WEB伺服器是無狀態,但我們的業務需求卻要求有狀態,典型的就是使用者登入, 在這種業務需求中,要求WEB伺服器端能區分某個請求是不是一個已登入使用者發起的,或者當前請求是哪個使用者發出的。 在開發WEB應用程式時,我們通常會使用Cookie來儲存一些簡單的資料供服務端維持必要的狀態。 既然這是個通常的做法,那我們現在就來看一下現在頁面的Cookie使用情況吧,以下是我用FireFox所看到的Cookie列表:

這個名字:LoginCookieName,是我在web.config中指定的:

<authentication mode="Forms" >
    <forms cookieless="UseCookies" name="LoginCookieName" loginUrl="~/Default.aspx"></forms>
</authentication>

在這段配置中,我不僅指定的登入狀態的Cookie名,還指定了身份驗證模式,以及Cookie的使用方式。

為了判斷這個Cookie是否與登入狀態有關,我們可以在瀏覽器提供的介面刪除它,然後重新整理頁面,此時頁面的顯示效果如下:

此時,頁面顯示當前使用者沒有登入。

為了確認這個Cookie與登入狀態有關,我們可以重新登入,然後再退出登入。
發現只要是頁面顯示當前使用者未登入時,這個Cookie就不會存在。

事實上,通過SetAuthCookie這個方法名,我們也可以猜得出這個操作會寫一個Cookie。
注意:本文不討論無Cookie模式的Forms登入。

從前面的截圖我們可以看出:雖然當前使用者名稱是 Fish ,但是,Cookie的值是一串亂碼樣的字串。
由於安全性的考慮,ASP.NET對Cookie做過加密處理了,這樣可以防止惡意使用者構造Cookie繞過登入機制來模擬登入使用者。 如果想知道這串加密字串是如何得到的,那麼請參考後文。

小結:
1. Forms身份認證是在web.config中指定的,我們還可以設定Forms身份認證的其它配置引數。
2. Forms身份認證的登入狀態是通過Cookie來維持的。
3. Forms身份認證的登入Cookie是加密的。

回到頂部

理解Forms身份認證

經過前面的Cookie分析,我們可以發現Cookie的值是一串加密後的字串, 現在我們就來分析這個加密過程以及Cookie對於身份認證的作用。

登入的操作通常會檢查使用者提供的使用者名稱和密碼,因此登入狀態也必須具有足夠高的安全性。 在Forms身份認證中,由於登入狀態是儲存在Cookie中,而Cookie又會儲存到客戶端,因此,為了保證登入狀態不被惡意使用者偽造, ASP.NET採用了加密的方式儲存登入狀態。 為了實現安全性,ASP.NET採用【Forms身份驗證憑據】(即FormsAuthenticationTicket物件)來表示一個Forms登入使用者, 加密與解密由FormsAuthentication的Encrypt與Decrypt的方法來實現。

使用者登入的過程大致是這樣的:
1. 檢查使用者提交的登入名和密碼是否正確。
2. 根據登入名建立一個FormsAuthenticationTicket物件。
3. 呼叫FormsAuthentication.Encrypt()加密。
4. 根據加密結果建立登入Cookie,並寫入Response。
在登入驗證結束後,一般會產生重定向操作, 那麼後面的每次請求將帶上前面產生的加密Cookie,供伺服器來驗證每次請求的登入狀態。

每次請求時的(認證)處理過程如下:
1. FormsAuthenticationModule嘗試讀取登入Cookie。
2. 從Cookie中解析出FormsAuthenticationTicket物件。過期的物件將被忽略。
3. 根據FormsAuthenticationTicket物件構造FormsIdentity物件並設定HttpContext.User
4. UrlAuthorizationModule執行授權檢查。

在登入與認證的實現中,FormsAuthenticationTicket和FormsAuthentication是二個核心的型別, 前者可以認為是一個數據結構,後者可認為是處理前者的工具類。

UrlAuthorizationModule是一個授權檢查模組,其實它與登入認證的關係較為獨立, 因此,如果我們不使用這種基於使用者名稱與使用者組的授權檢查,也可以禁用這個模組。

由於Cookie本身有過期的特點,然而為了安全,FormsAuthenticationTicket也支援過期策略, 不過,ASP.NET的預設設定支援FormsAuthenticationTicket的可調過期行為,即:slidingExpiration=true 。 這二者任何一個過期時,都將導致登入狀態無效。

FormsAuthenticationTicket的可調過期的主要判斷邏輯由FormsAuthentication.RenewTicketIfOld方法實現,程式碼如下: 

Request.IsAuthenticated可以告訴我們當前請求是否已經過身份驗證, 我們來看一下這個屬性是如何實現的:

public bool IsAuthenticated
{
    get
    {
        return (((this._context.User != null) 
            && (this._context.User.Identity != null)) 
            && this._context.User.Identity.IsAuthenticated);
    }
}

從程式碼可以看出,它的返回結果基本上來源於對Context.User的判斷。
另外,由於User和Identity都是二個介面型別的屬性,因此,不同的實現方式對返回值也有影響。

由於可能會經常使用HttpContext.User這個例項屬性,為了讓它能正常使用, DefaultAuthenticationModule會在ASP.NET管線的PostAuthenticateRequest事件中檢查此屬性是否為null, 如果它為null,DefaultAuthenticationModule會給它一個預設的GenericPrincipal物件,此物件指示一個未登入的使用者。

我認為ASP.NET的身份認證的最核心部分其實就是HttpContext.User這個屬性所指向的物件。
為了更好了理解Forms身份認證,我認為自己重新實現User這個物件的介面會有較好的幫助。

回到頂部

實現自定義的身份認證標識

前面演示了最簡單的ASP.NET Forms身份認證的實現方法,即:直接呼叫SetAuthCookie方法。 不過呼叫這個方法,只能傳遞一個登入名。 但是有時候為了方便後續的請求處理,還需要儲存一些與登入名相關的額外資訊。 雖然知道ASP.NET使用Cookie來儲存登入名狀態資訊,我們也可以直接將前面所說的額外資訊直接儲存在Cookie中, 但是考慮安全性,我們還需要設計一些加密方法,而且還需要考慮這些額外資訊儲存在哪裡才能方便使用, 並還要考慮隨登入與登出同步修改。 因此,實現這些操作還是有點繁瑣的。

為了儲存與登入名相關的額外的使用者資訊,我認為實現自定義的身份認證標識(HttpContext.User例項)是個容易的解決方法。
理解這個方法也會讓我們對Forms身份認證有著更清楚地認識。

這個方法的核心是(分為二個子過程):
1. 在登入時,建立自定義的FormsAuthenticationTicket物件,它包含了使用者資訊。
2. 加密FormsAuthenticationTicket物件。
3. 建立登入Cookie,它將包含FormsAuthenticationTicket物件加密後的結果。
4. 在管線的早期階段,讀取登入Cookie,如果有,則解密。
5. 從解密後的FormsAuthenticationTicket物件中還原我們儲存的使用者資訊。
6. 設定HttpContext.User為我們自定義的物件。

現在,我們還是來看一下HttpContext.User這個屬性的定義:

//  為當前 HTTP 請求獲取或設定安全資訊。
//
// 返回結果:
//     當前 HTTP 請求的安全資訊。
public IPrincipal User { get; set; }

由於這個屬性只是個介面型別,因此,我們也可以自己實現這個介面。
考慮到更好的通用性:不同的專案可能要求接受不同的使用者資訊型別。所以,我定義了一個泛型類。

public class MyFormsPrincipal<TUserData> : IPrincipal
    where TUserData : class, new()
{
    private IIdentity _identity;
    private TUserData _userData;

    public MyFormsPrincipal(FormsAuthenticationTicket ticket, TUserData userData)
    {
        if( ticket == null )
            throw new ArgumentNullException("ticket");
        if( userData == null )
            throw new ArgumentNullException("userData");

        _identity = new FormsIdentity(ticket);
        _userData = userData;
    }
    
    public TUserData UserData
    {
        get { return _userData; }
    }

    public IIdentity Identity
    {
        get { return _identity; }
    }

    public bool IsInRole(string role)
    {
        // 把判斷使用者組的操作留給UserData去實現。

        IPrincipal principal = _userData as IPrincipal;
        if( principal == null )
            throw new NotImplementedException();
        else
            return principal.IsInRole(role);
    }

與之配套使用的使用者資訊的型別定義如下(可以根據實際情況來定義): 

注意:表示使用者資訊的型別並不要求一定要實現IPrincipal介面,如果不需要使用者組的判斷,可以不實現這個介面。

登入時需要呼叫的方法(定義在MyFormsPrincipal型別中):

/// <summary>
/// 執行使用者登入操作
/// </summary>
/// <param name="loginName">登入名</param>
/// <param name="userData">與登入名相關的使用者資訊</param>
/// <param name="expiration">登入Cookie的過期時間,單位:分鐘。</param>
public static void SignIn(string loginName, TUserData userData, int expiration)
{
    if( string.IsNullOrEmpty(loginName) )
        throw new ArgumentNullException("loginName");
    if( userData == null )
        throw new ArgumentNullException("userData");

    // 1. 把需要儲存的使用者資料轉成一個字串。
    string data = null;
    if( userData != null )
        data = (new JavaScriptSerializer()).Serialize(userData);


    // 2. 建立一個FormsAuthenticationTicket,它包含登入名以及額外的使用者資料。
    FormsAuthenticationTicket ticket = new FormsAuthenticationTicket(
        2, loginName, DateTime.Now, DateTime.Now.AddDays(1), true, data);


    // 3. 加密Ticket,變成一個加密的字串。
    string cookieValue = FormsAuthentication.Encrypt(ticket);


    // 4. 根據加密結果建立登入Cookie
    HttpCookie cookie = new HttpCookie(FormsAuthentication.FormsCookieName, cookieValue);
    cookie.HttpOnly = true;
    cookie.Secure = FormsAuthentication.RequireSSL;
    cookie.Domain = FormsAuthentication.CookieDomain;
    cookie.Path = FormsAuthentication.FormsCookiePath;
    if( expiration > 0 )
        cookie.Expires = DateTime.Now.AddMinutes(expiration);

    HttpContext context = HttpContext.Current;
    if( context == null )
        throw new InvalidOperationException();

    // 5. 寫登入Cookie
    context.Response.Cookies.Remove(cookie.Name);
    context.Response.Cookies.Add(cookie);
}

這裡有必要再補充一下: 登入狀態是有過期限制的。Cookie有 有效期,FormsAuthenticationTicket物件也有 有效期。 這二者任何一個過期時,都將導致登入狀態無效。 按照預設設定,FormsAuthenticationModule將採用slidingExpiration=true的策略來處理FormsAuthenticationTicket過期問題。

登入頁面程式碼:

<fieldset><legend>包含【使用者資訊】的自定義登入</legend>    <form action="<%= Request.RawUrl %>" method="post">
    <table border="0">
    <tr><td>登入名:</td>
        <td><input type="text" name="loginName" style="width: 200px" value="Fish" /></td></tr>
    <tr><td>UserId:</td>
        <td><input type="text" name="UserId" style="width: 200px" value="78" /></td></tr>
    <tr><td>GroupId:</td>
        <td><input type="text" name="GroupId" style="width: 200px" />
        1表示管理員使用者
        </td></tr>
    <tr><td>使用者全名:</td>
        <td><input type="text" name="UserName" style="width: