[MVC] 實作客製化的identity

  • 7591
  • 0
  • 2016-07-19

實作客製化的AspNet identity

  ASP.NET的Identity可以追朔到2005年,但實際真正應用在專案上的人似乎不多,可能是因為覺得彈性不夠的關係吧。到了2015年,微軟也真的進步得非常快,identity現在已經可以客製化出屬於自己需求的驗證功能。再加上現在社群的蓬勃發展,如果可以讓使用者簡單透過社群的帳號註冊自己網站的登入帳號,相信讓用戶願意註冊的機率將會大大提升。但本篇暫時不對如何實做第三方驗證這塊多做說明,而是希望能簡單的實作一個自訂的identity來滿足客製化的需求,在完成主要架構之後,要與第三方的帳號做驗證,其實就是申請第三方帳號的secret id和做一些Config設定而已。

  首先當然是要新增一個MVC專案,並勾選individual User Account來進行驗證,然後按完成,進入專案之後點開Startup類別。

using Owin;//定義IAppBuilder介面
using Microsoft.Owin;//OwinStartup的命名空間,擴充IAppBuilder介面

[assembly: OwinStartup(typeof(DempIdentity.Startup))]

namespace DempIdentity
{
     //部分類別
    public partial class Startup  
   {
        public void Configuration(IAppBuilder app)
        {
            ConfigureAuth(app);//實作封裝在Startup.Auth
        }
    }
}

  先來觀察Startup類別,它引入了一個叫做Owin的命名空間,定義了Open Web Interface相關的內容,Configuration方法內的參數類型"IAppBuilder"就是定義在Owin內。

using Microsoft.AspNet.Identity;
using Microsoft.AspNet.Identity.Owin;
using Microsoft.Owin;
using Microsoft.Owin.Security.Cookies;
using Owin;
using System;

namespace ErpNext
{
    public partial class Startup
    {
        public void ConfigureAuth(IAppBuilder app)
        {
            // 設定資料庫內容和使用者管理員以針對每個要求使用單一執行個體
            app.CreatePerOwinContext(CustomUserManager.Create);
            app.CreatePerOwinContext<CustomSignInManager>(CustomSignInManager.Create);

            // 讓應用程式使用 Cookie 儲存已登入使用者的資訊
            app.UseCookieAuthentication(new CookieAuthenticationOptions
            {
                AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
                LoginPath = new PathString("/Home/UnAuth"),
                Provider = new CookieAuthenticationProvider
                {
                    //當使用者登入後可透過戳記來來驗證其登入合法性
                    OnValidateIdentity = SecurityStampValidator.OnValidateIdentity<CustomUserManager, CustomUser>(
                       validateInterval: TimeSpan.FromDays(1),
                       regenerateIdentity: (manager, user) => manager.CreateIdentityAsync(user, DefaultAuthenticationTypes.ApplicationCookie))
                }
            });
        }
    }
}

  接下來再來看Startup.Auth類別,我們可以發現它也是Startup的部分類別(partial),CreatePerOwinContext提供註冊一個Callback的方法,CallBack方法會回傳指定型別的一個實例(透過泛型),以本例子而言,我們註冊了兩個Callback方法,產生了CustomUserManager(包含管理User的相關邏輯)和CustomSignInManager(包含管理登入的相關邏輯)的實例,這兩個實例會存放在OwinContext,並透過context.Get的方式讀取,最後在實作Controller時會有例子,這裡先知道一下就好,後續會再完成CustomUserManager和CustomSignInManager的實作;再來需要定義UseCookieAuthentication(使用者在成功登入後,使用者驗證的資訊儲存的方式),在本例是透過UseCookieAuthentication來達成,有下列r幾個設定,第一個設定AuthenticationType(資料存放的方式),本例是以ApplicationCookie方式儲存;接下來設定LoginPath(登入頁面的路徑),如果使用者在沒有通過驗證的情況下訪問需驗證的網頁,便會被導向登入頁面;再來定義Provider,可設定驗證的時效,以及過期時可以透過regenerateIdentity內的callback方法重新驗證。

  到這裡基本的架構已經完成,剩下來的便是實作CustomUserManager和CustomSignInManager的類別,就可以完成整個驗證流程。

  public interface IUser<out TKey>
    {
        //
        // Summary:
        //     Unique key for the user
        TKey Id { get; }
        //
        // Summary:
        //     Unique username
        string UserName { get; set; }
    }

  但在那之前,我們得先根據我們客製化的需求來定義我們的User類別,原生的介面已提供了Id和UserName兩個屬性,但一般來說並不是很足夠,所以需要再定義一個自訂的User類別並實作IUser。

  public class CustomUser : IUser<string>
    {
        public string Id { get; set; }
        public string UserName { get; set; }
        public string Email{ get; set; }
  }

  為了避免不必要的複雜化,只增加一個Email的欄位來展示如何實作CustomUser即可。

 public class CustomSignInManager : SignInManager<CustomUser, string>
    {
        public CustomSignInManager(CustomUserManager userManager, IAuthenticationManager authenticationManager)
            : base(userManager, authenticationManager)
        {
        }
    }

  再來便是實作CustomSignInManager,一樣繼承SignInManager,在建構子的部分為了不增加複雜度,所以不添加任何擴充功能的程式碼。

public static CustomSignInManager Create(IdentityFactoryOptions<CustomSignInManager> options, IOwinContext context)
{
       return new CustomSignInManager(context.GetUserManager<CustomUserManager>(), context.Authentication);
}

  定義一個靜態的Create的方法,這方法主要是提供給Startup.Auth定義的CreatePerOwinContext的當作它的callback方法已產生自訂的CustomSignInManager實例。

public override async Task<SignInStatus> PasswordSignInAsync(string empno, string password, bool isPersistent = false, bool shouldLockout = true)
        {
            var user = await UserManager.FindAsync(empno, password);
            if (user != null)
            {
                return SignInStatus.Success;
            }
            else
            {
                return SignInStatus.Failure;
            }
        }

  SignInManager處理與登入相關的邏輯,但真正在處理判斷user密碼和帳號是否存在的事情,建議封裝給UserManager處理,此處只根據UserManager是否回傳User來判斷是否成功登入,並回傳登入狀態,以達到單一職責的原則。

   public class CustomSignInManager : SignInManager<CustomUser, string>
    {
        public CustomSignInManager(CustomUserManager userManager, IAuthenticationManager authenticationManager)
            : base(userManager, authenticationManager)
        {
        }

        public static CustomSignInManager Create(IdentityFactoryOptions<CustomSignInManager> options, IOwinContext context)
        {
            return new CustomSignInManager(context.GetUserManager<CustomUserManager>(), context.Authentication);
        }

        public override async Task<SignInStatus> PasswordSignInAsync(string empno, string password, bool isPersistent = false, bool shouldLockout = true)
        {
            var user = await UserManager.FindAsync(empno, password);
            if (user != null)
            {
                return SignInStatus.Success;
            }
            else
            {
                return SignInStatus.Failure;
            }
        }
    }

  以上為CustomSignInManager 完整的程式碼。

   public class CustomUserManager : UserManager<CustomUser>
    {
        public CustomUserManager(IUserStore<CustomUser> store)
            : base(store)
        {
        }
    }

  再來便是實作UserManager,一樣宣告一個CustomUserManager繼承基礎類別,以上為建構子,為了不增加複雜度,暫時不擴充任何程式碼。

public static CustomUserManager Create()
{
       //實例CustomUserManager 並給予任意實作IUserStore(負責CRUD實作)的類別當作參數
        var manager = new CustomUserManager(new CustomUserStore());
        return manager;
}

  此處同樣定義一個靜態方法,這方法主要是提供給Startup.Auth定義的CreatePerOwinContext的當作它的callback方法已產生自訂的CustomManager實例。

  public override Task<ClaimsIdentity> CreateIdentityAsync(CustomUser user, string authenticationType)
{
            Task<ClaimsIdentity> baseTask = base.CreateIdentityAsync(user, authenticationType);

            Task<ClaimsIdentity> task = Task.Run(() =>
            {
                ClaimsIdentity identity = baseTask.Result;

                List<Claim> claims = new List<Claim>{
                        new Claim("Email", user.Email), 
                };

                identity.AddClaims(claims);
                return identity;
            });
            return task;
 }

  這裡是重點,在建立identity時,我們必須覆寫原本的方法,主要目的只有一個,便是我們的Custom有多一個Email的欄位,如果走原本的實作方式,我們便無法在identity裡取得Email的資訊。而實做的方式首先先呼叫原本父類別的方法產生基本的identity,接下來將我們想要額外儲存的資訊儲存在identity的Claims內,Claim是一組dictionary,完成之後便可將結果回傳,此方法是用非同步的方式實做的,所以必須回傳一個Task。

  public override  Task<CustomUser> FindAsync(string EMPNO, string password)
 {
      Task<CustomUser> task = Task.Run(() =>
      {
           return new CustomUser
           {
                    Id = EMPNO,
                    Email= "xxx.xxx.com.tw",
                    UserName = ""
            };
       });
}

  這裡就稍微偷懶了,實際上應該要去資料庫或任何資料來源,搜尋這組帳密是否存在,並將相關的資訊封裝到CustomUser內回傳,此處省略了查找資料庫的邏輯,而直接回傳一組實例化的CustomUser。這裡要強調一個地方,UserName和Id是來自IUser介面,就算你不想用,也請給他一個值,假如未給資料,會造成null的錯誤。

  public class CustomUserManager : UserManager<CustomUser>
    {
        public CustomUserManager(IUserStore<CustomUser> store)
            : base(store)
        {
        }

        public static CustomUserManager Create()
        {
            var manager = new CustomUserManager(new CustomUserStore());
            return manager;
        }

      public override Task<ClaimsIdentity> CreateIdentityAsync(CustomUser user, string authenticationType)
      {
            Task<ClaimsIdentity> baseTask = base.CreateIdentityAsync(user, authenticationType);

            Task<ClaimsIdentity> task = Task.Run(() =>
            {
                ClaimsIdentity identity = baseTask.Result;

                List<Claim> claims = new List<Claim>{
                        new Claim("Email", user.Email), 
                };

                identity.AddClaims(claims);
                return identity;
            });
            return task;
      }

      public override  Task<CustomUser> FindAsync(string EMPNO, string password)
      {
           Task<CustomUser> task = Task.Run(() =>
           {
               return new CustomUser
                {
                    Id = EMPNO,
                    Email= "xxx.xxx.com.tw",
                    UserName = ""
                 };
           });
       }
}

  以上為CustomManager 完整的程式碼。

 public class CustomUserStore : IUserStore<CustomUser>
    {
        /// <summary>
        /// ERP CRUD不在此處做
        /// </summary>
        /// <param name="user"></param>
        /// <returns></returns>
        public  Task CreateAsync(CustomUser user)
        {
            throw new NotImplementedException();
        }

        /// <summary>
        /// ERP CRUD不在此處做
        /// </summary>
        /// <param name="user"></param>
        /// <returns></returns>
        public  Task DeleteAsync(CustomUser user)
        {
             throw new NotImplementedException();
        }
        /// <summary>
        /// 
        /// </summary>
        /// <param name="userId"></param>
        /// <returns></returns>
        public  Task<CustomUser> FindByIdAsync(string userId)
        {
            throw new NotImplementedException();
        }

        /// <summary>
        /// 
        /// </summary>
        /// <param name="userName"></param>
        /// <returns></returns>
        public Task<CustomUser> FindByNameAsync(string userName)
        {
            throw new NotImplementedException();
        }

        /// <summary>
        /// 
        /// </summary>
        /// <param name="user"></param>
        /// <returns></returns>
        public Task UpdateAsync(CustomUser user)
        {
            throw new NotImplementedException();
        }

        public void Dispose()
        {
        }
    }

  最後便是CustomUser資料該如何儲存,此處就是相關的CRUD實作,這裡就不特別說明了。

  走到這裡,來回顧一下我們做了甚麼,我們定義了Startup(Owin的進入點)和Sartup.Auth(設定Owin相關的Config) 、CustomSignManager(處理是否登入成功的相關邏輯)、CustomManager(處理使用者密碼帳密比對等相關問題)、CustomUser(自訂的使用者類別)、CustomUserStore(管理CustomUser資料庫的CRUD實作),以上都完成後,便完成了自訂的Owin Identity實作。接下來便來看看我們的程式如何使用它吧。

public class DemoController : Controller
    {
        private CustomSignInManager _signInManager;
        public CustomSignInManager CustomSignInManager
        {
            get
            {
                //透過Owin的框架取得CustomSignInManager(Startup.Auth 的CreatePerOwinContext註冊)
                return _signInManager ?? HttpContext.GetOwinContext().Get<CustomSignInManager>();
            }
            private set { _signInManager = value; }
        }

        public IAuthenticationManager AuthenticationManager
        {
            get
            {
                //取得目前使用者的驗證,可用來做登出的功能
                return HttpContext.GetOwinContext().Authentication;
            }
        }
}

  在Controller內,我們便可以透過HttpContext內的GetOwinContext方法,取得註冊的CustomSignInManager來處理登入以及取得目前使用者的Authentication。

        [AllowAnonymous]
        [HttpPost]
        public async Task<ActionResult> Index(string Empno,string password)
        {
            //判斷是否已驗證,如無驗證則呼叫登入的方法
            if (!Request.IsAuthenticated) { 
               await CustomSignInManager.PasswordSignInAsync(Empno,password); 
            }

            //取得identity的Claims 為了要取得我們自定義的Email欄位
            var identities = ((ClaimsIdentity)HttpContext.User.Identity).Claims;
            
            foreach(var item in identities)
            {
                switch (item.Type)
                {
                    case "Email":
                        ViewBag.Email= item.Value;
                        break;
                 }
            }

            return View();
        }

      [HttpPost]
        public ActionResult LogOff()
        {
            AuthenticationManager.SignOut(DefaultAuthenticationTypes.ApplicationCookie);
            return View();
        }

  簡單處理登入功能,以及透過Claims取得額外新增進去的Email欄位,以及登出的功能,大功告成。