2013年12月9日 星期一

ASP.NET MVC 實做具有多個角色權限的登入功能 - 使用客製 RoleProvider

之前的文章「ASP.NET MVC 實做具有多個角色權限的登入功能」裡我們做出了一個登出入的功能,使用者登入成功之後將使用者的多個角色資料給放置到 FormAuthentication 的 UserData 裡,當有需要驗證使用者時,在 HttpApplication.AuthenticateRequest 事件裡取得使用者的識別 ( Identity ) 並且建立 IPrincipal 物件然後放到 HttpContext.Current.User 中,其中的 IPricipal 物件包含 FormsIdentity 以及使用者角色資料。
之前的例子是我們在處理使用者的登入時,當使用者登入成功後會建立 FormsAuthenticationTicket 物件,然後於加密後存入 Cookie 之中,而我們是把 FormsAuthenticationTicket 的 Name 用來放使用者的 ID,而 UserData 則是用來放 Roles(角色資料)。
其實我們可以改用其他的做法,這邊將會介紹我們可以繼承 RoleProvider 以建立自己的 RoleProvider,讓 FormsAuthenticationTicket 的 Name 與 UserData 分別存放使用者顯示名稱與使用者資料 ID。
 

首先要先對原本的程式做點小變動,我習慣會把使用者登出入功能做在一個獨立的 Controller 裡,
image
一開始有說過,使用者登入成功之後所建立的 FormsAuthenticationTicket 裡,Name 是放使用者資料的顯示名稱,UserData 則是放使用者資料的 ID,而 FormsAuthenticationTicket 現在就不會存放使用者的角色資料,
image
 
接著在專案理建立一個「Modules」的資料夾,並且建立 CustomRoleProvider.cs 的檔案,
image
開啟 CustomerRoleProvider.cs 檔案,然後繼承並且實作抽象類別「RoleProvider」,
image
抽象類別「RoleProvider」裡有很多方法,但我們現在只需要先實作「GetRolesForUser」這個方法,其餘的先擺著不動,
image
image
稍做修改之後的實作程式內容,
image
 
再來要修改的是 Global.asax 的 Application_AuthenticateRequest 事件,原本的作法因為已經將使用者的角色資料給放到 FormsAuthentucationTicket 的 UserData 當中,但現在放在 UserData 的是使用者資料的 ID,所以使用者的角色資料就可以透過剛才所建立 CustomRoleProvider 的 GetRolesForUser 方法來取得,
image
 
但是做到這裡還沒有完成,因為是我們自己所建立的 RoleProvider,必須讓系統執行時能夠知道要用我們所建立的 CustomRoleProvider,所以要在 Web.Config 的 System.Web 區段下添加 RoleManager 的定義,
MSDN - roleManager 項目 (ASP.NET 設定結構描述)
image
 
執行網站於登入成功後經由 CustomRoleProvider 的 GetRolesForUser 方法取得使用者的角色資料,
image
如果覺得登入成功之後,當瀏覽有需要權限的頁面都會從資料庫裡把使用者的角色資料讀取出來的話,可以將角色資料放到快取裡,然後在使用者登出的時候再把使用者的角色資料從快取裡移除。
例如:
/// <summary>
/// 取得所設定 applicationName 之指定使用者所屬的角色清單。
/// </summary>
/// <param name="userId">要傳回角色清單的使用者。</param>
/// <returns>
/// 字串陣列,其中包含所設定 applicationName 之指定使用者所屬的所有角色名稱。
/// </returns>
/// <exception cref="System.NotImplementedException"></exception>
public override string[] GetRolesForUser(string userId)
{
    Guid id;
    if (!Guid.TryParse(userId, out id))
    {
        return new string[] { string.Empty };
    }
    else
    {
        string cacheKey = string.Concat("UserRoles_", userId);
 
        ObjectCache cache = MemoryCache.Default;
 
        var userRoles = cache.GetCacheItem(cacheKey) == null
            ? RetriveRolesForUser(id, cacheKey)
            : cache.GetCacheItem(cacheKey).Value == null
                ? RetriveRolesForUser(id, cacheKey)
                : cache.GetCacheItem(cacheKey).Value as string[];
 
        return userRoles;
    }
}
 
private string[] RetriveRolesForUser(Guid id, string cacheKey)
{
    var user = db.SystemUsers
                 .Include(x => x.SystemRoles)
                 .FirstOrDefault(x => x.ID == id);
 
    if (user == null) return new string[] { string.Empty };
 
    var userRoles = user.SystemRoles.Select(x => x.Name).ToArray();
 
    CacheItemPolicy policy = new CacheItemPolicy
    {
        SlidingExpiration = new TimeSpan(0, 0, 10, 0)
    };
    ObjectCache cacheItem = MemoryCache.Default;
    cacheItem.Add(cacheKey, userRoles, policy);
 
    return userRoles;
}
image
 
再來繼承原本的 AuthorizeAttribute 這個 Action Filters,然後在 CustomRoleProvider 裡實作 IsUserInRoles 方法的內容,
image
在專案裡建立 Filters 資料夾,然後新增「CustomAuthorizeAttribute.cs」(這個命名原則一定要切記,有關 Attribute 類別的檔案名稱的字尾一定要加上 Attribute,以示區別,然後建立 Filters 或 ActionFilters 為名稱的資料來放置這些檔案),接著繼承 AuthorizeAttribute,
image
public class CustomAuthorizeAttribute : AuthorizeAttribute
{
    public bool IsAuthorize { get; set; }
 
    public override void OnAuthorization(AuthorizationContext filterContext)
    {
        base.OnAuthorization(filterContext);
 
        if (!this.IsAuthorize)
        {
            filterContext.HttpContext.Response.StatusCode = 403;
 
            //沒有權限就回到首頁
            UrlHelper urlHelper = new UrlHelper(filterContext.RequestContext);
            filterContext.Result = new RedirectResult(urlHelper.Action("Index", "Home"));
        }
    }
 
    protected override bool AuthorizeCore(HttpContextBase httpContext)
    {
        var identity = httpContext.User.Identity as FormsIdentity;
 
        var roles = this.Roles.Split(',');
 
        this.IsAuthorize = roles.Count(role =>
            System.Web.Security.Roles.IsUserInRole(identity.Ticket.UserData, role)) > 0;
 
        return this.IsAuthorize;
    }
}
 

使用

在指定的 Action 方法上使用我們所建立的 CustomAuthorizeAttribute,然後指定允許進入的角色名稱,如有多個就用「,」分隔,其實就跟使用原本的 AuthorizeAttribute 一樣,只是因為是使用我們自己所建立的 RoleProvider,如果使用原本的 AuthorizeAttribute 會無法正確的判斷使用者角色資料,所以我們就另外建立 CustomAuthorizeAttribute 來使用。
以下的設定是限定只能擁有 SystemAdmin 或 WebAdmin 角色的使用者才能進入,
image
 


有時候會與到一些特殊的狀況,使得我們無法使用預設的方式來處理使用者權限的操作,這時候可以透過上面的方式來做處理,不過需要考慮與修改還有調整的地方會更多,所以要特別注意。

 

參考連結:

MSDN - HttpContext.User 屬性 (System.Web)

MSDN - FormsIdentity 類別 (System.Web.Security)
MSDN - GenericPrincipal 類別 (System.Security.Principal)
MSDN - HttpApplication.AuthenticateRequest 事件 (System.Web)
 
以上

4 則留言:

  1. 大大您好,感謝您的教學文章,非常詳細!

    AuthorizeCore 這方法裡面 呼叫的 IsUserInRole 的 roleName參數 不能放入 空值,否則會拋出錯誤

    // Exceptions:
    // T:System.ArgumentNullException:
    // roleName is null.-or-username is null.
    //
    // T:System.ArgumentException:
    // roleName is an empty string or contains a comma (,).-or-username contains a comma
    // (,).
    //
    // T:System.Configuration.Provider.ProviderException:
    // Role management is not enabled.


    如果有遇到有些方法只需要有帳號就能用的話,
    請記得在呼叫前用 string.IsNullOrWhiteSpace(this.Roles) 避開。

    以上是小弟實作上遇到的狀況以及解法!!

    不過一般如果只要身份過不用到角色的話,是靠web.Config的設定表單驗證保護就能做到了!

    回覆刪除
    回覆
    1. 其實在實際專案的使用我並不是用這篇所寫的方式,這篇所寫的方式最比較基本的做法,
      因為權限所包含的角色、使用者等相關的資料都是需要動態去讀去資料庫來處理,而不會以寫死在程式碼裡,
      而這一篇很明確的就已經說明是要有「多種角色」的做法,所以當然一定會有角色的資料存在呀,
      當然這個範例是沒有很嚴謹的去對輸入參數作驗證,當輸入參數有誤的時候就拋出 Exception 或是做另外的處置。

      刪除
  2. 版大你好 想請問一下
    var user = db.SystemUsers.Include(x => x.SystemRoles).FirstOrDefault(x => x.ID == id);
    目前我用VS2019的MVC5撰寫專案
    會出現 " 無法將 Lambda 運算式 轉換成類型 'string',因為其非委派類型。 "

    然後我看目前Include所提供的方法只有
    public virtual DbQuery Include(string path);
    如果還是要用您目前的寫法 要怎麼修改呢 謝謝

    回覆刪除
    回覆
    1. 不好意思耍笨了把include那段拿掉即可

      刪除

提醒

千萬不要使用 Google Talk (Hangouts) 或 Facebook 及時通訊與我聯繫、提問,因為會掉訊息甚至我是過了好幾天之後才發現到你曾經傳給我訊息過,請多多使用「詢問與建議」(在左邊,就在左邊),另外比較深入的問題討論,或是有牽涉到你實作程式碼的內容,不適合在留言板裡留言討論,請務必使用「詢問與建議」功能(可以夾帶檔案),謝謝。