ASP.NET MVC 5 如何客製化驗證與授權並實作帳號登入機制 (How to custom ASP.NET MVC Auth Filters & Login / Logoff systems)

2020-06-21

夏至的這天上午先去爬山,正午時分不一會就滿身大汗了,果然還是秋天的午後才是最適合的爬山時機。而最近寫程式的機會越來越多,不管是主管刻意的安排或者自己興趣的傾向,總之寫程式在工作中是越來越重要且主要的一部份了。自從有寫筆記的習慣之後,對於學習陳果可以被視覺化的呈現,同時也可以量化,這種看得見累積的感覺讓人更加有動力去學習。

最近深有所感,只有經驗過、練習過的才能真正在開發的時候發揮作用,並且沉入心流的開發體驗。驗證與授權一直是寫每個系統的起手式,特此跟隨一篇教學文章,並整理成筆記。

logo

範例是採 CodeFirst 的方式進行,且使用帳號密碼的方式管理驗證。

範例程式碼

Step By Step

新建專案

ASP.NET MVC

.NET Framework : 4.52
驗證方式: 無驗證
套件: MVC

安裝套件

EntityFramework

Codefirst - 設計 Models (User, Role)

/Models/Role.cs

namespace Aspnet_MVC_AuthFilter_POC.Models
{
    public class Role
    {
        public int Id { get; set; }
        public string Name { get; set; }
    }
}

/Models/User.cs

using System.ComponentModel.DataAnnotations;
namespace Aspnet_MVC_AuthFilter_POC.Models
{
    public class User
    {
        public int Id { get; set; }
        [Display(Name = "使用者名稱")]
        public string UserId { get; set; }
        public string UserName { get; set; }
        public string Password { get; set; }
        public int RoleId { get; set; }
    }
}

Codefirst - 加入 DBContext 與 ConnectionStrings

/Models/SqlDbContext.cs

using System.Data.Entity;

namespace Aspnet_MVC_AuthFilter_POC.Models
{
    public class SqlDbContext : DbContext
    {
        public SqlDbContext() : base("name=SqlConnection"){}
        public DbSet<User> Users { get; set; }
        public DbSet<Role> Roles { get; set; }
    }
}

/web.config

<connectionStrings>
  <add name="SqlConnection"
        connectionString="Data Source=(localdb)\MSSQLLocalDB; Initial Catalog=MvcAuthPOC; Integrated Security=SSPI"
        providerName="System.Data.SqlClient"
        />
</connectionStrings>

Codefirst - Enable Migrations

使用 Nuget Package Manager (套件管理器主控台)

enable-migrations

執行完 enable-migrations 後,系統會自動產生 Migrations Folder 及 Configuration.cs。

add-migration Initial

執行完 add-migration Initial 後,系統會自動產生 日期時間_Initial.cs,裡面包含著從 Models 藉由 EntityFramework 到 Database 建立 Table 的互動。這邊可以加入產生預設資料的 SQL Command,方便預設資料不用每次都重新建立。

/Migrations/_Initial.cs

namespace Aspnet_MVC_AuthFilter_POC.Migrations
{
    using System;
    using System.Data.Entity.Migrations;

    public partial class Initial : DbMigration
    {
        public override void Up()
        {
            CreateTable(
                "dbo.Roles",
                c => new
                    {
                        Id = c.Int(nullable: false, identity: true),
                        Name = c.String(),
                    })
                .PrimaryKey(t => t.Id);

            CreateTable(
                "dbo.Users",
                c => new
                    {
                        Id = c.Int(nullable: false, identity: true),
                        UserId = c.String(),
                        UserName = c.String(),
                        Password = c.String(),
                        RoleId = c.Int(nullable: false),
                    })
                .PrimaryKey(t => t.Id);

            Sql("Insert into Roles (Name) Values ('SystemAdmin')");
            Sql("Insert into Roles (Name) Values ('Admin')");
            Sql("Insert into Roles (Name) Values ('User')");
            Sql("Insert into Users (UserId,UserName,Password,RoleId) Values ('acc1','Ryan System Admin','pwd',1)");
            Sql("Insert into Users (UserId,UserName,Password,RoleId) Values ('acc2','Josh Admin','pwd',2)");
            Sql("Insert into Users (UserId,UserName,Password,RoleId) Values ('acc3','Kevin User','pwd',3)");

        }

        public override void Down()
        {
            DropTable("dbo.Users");
            DropTable("dbo.Roles");
        }
    }
}

執行完 update-database 後,資料庫就會配合產生對應的資料表與預設資料了。

update-database

加入 AccountController

/Controller/AccountController.cs

using Aspnet_MVC_AuthFilter_POC.Models;
using System.Linq;
using System.Web.Mvc;

namespace Aspnet_MVC_AuthFilter_POC.Controllers
{
    public class AccountController : Controller
    {
        [HttpGet]
        public ActionResult Login()
        {
            return View();
        }

        [HttpPost]
        public ActionResult Login(User model)
        {
            if (ModelState.IsValid)
            {
                using (var context = new SqlDbContext())
                {
                    User user = context.Users
                                       .Where(u => u.UserId == model.UserId && u.Password == model.Password)
                                       .FirstOrDefault();

                    if (user != null)
                    {
                        Session["UserName"] = user.UserName;
                        Session["UserId"] = user.UserId;
                        return RedirectToAction("Index", "Home");
                    }
                    else
                    {
                        ModelState.AddModelError("", "Invalid User Name or Password");
                        return View(model);
                    }
                }
            }
            else
            {
                return View(model);
            }
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public ActionResult LogOff()
        {
            Session["UserName"] = string.Empty;
            Session["UserId"] = string.Empty;
            return RedirectToAction("Index", "Home");
        }
    }
}

客製化 Authentication Filter

這個部分也是整個教學文章的核心所在,如何客製驗證、授權的機制,則系統的開發就更能隨心所欲的設計,而使用 Filters 的方式同時也讓專案易於擴充與維護。

加入 Filters Folder

/Filters/CustomAuthenticationFilter.cs

using System;
using System.Web.Mvc;
using System.Web.Mvc.Filters;
using System.Web.Routing;

namespace Aspnet_MVC_AuthFilter_POC.Filters
{
    public class CustomAuthenticationFilter : ActionFilterAttribute, IAuthenticationFilter
    {
        public void OnAuthentication(AuthenticationContext filterContext)
        {
            if (string.IsNullOrEmpty(Convert.ToString(filterContext.HttpContext.Session["UserName"])))
            {
                filterContext.Result = new HttpUnauthorizedResult();
            }
        }
        public void OnAuthenticationChallenge(AuthenticationChallengeContext filterContext)
        {
            if (filterContext.Result == null || filterContext.Result is HttpUnauthorizedResult)
            {
                //Redirecting the user to the Login View of Account Controller  
                filterContext.Result = new RedirectToRouteResult(
                new RouteValueDictionary
                {
                     { "controller", "Account" },
                     { "action", "Login" }
                });
            }
        }
    }
}

客製化 Authorize Attribute

/Filters/CustomAuthorizeAttribute.cs

using Aspnet_MVC_AuthFilter_POC.Models;
using System;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Routing;

namespace Aspnet_MVC_AuthFilter_POC.Filters
{
    public class CustomAuthorizeAttribute : AuthorizeAttribute
    {
        private readonly string[] allowedroles;
        public CustomAuthorizeAttribute(params string[] roles)
        {
            this.allowedroles = roles;
        }
        protected override bool AuthorizeCore(HttpContextBase httpContext)
        {
            bool authorize = false;
            var userId = Convert.ToString(httpContext.Session["UserId"]);
            if (!string.IsNullOrEmpty(userId))
                using (var context = new SqlDbContext())
                {
                    var userRole = (from u in context.Users
                                    join r in context.Roles on u.RoleId equals r.Id
                                    where u.UserId == userId
                                    select new
                                    {
                                        r.Name
                                    }).FirstOrDefault();
                    foreach (var role in allowedroles)
                    {
                        if (role == userRole.Name) return true;
                    }
                }


            return authorize;
        }

        protected override void HandleUnauthorizedRequest(AuthorizationContext filterContext)
        {
            filterContext.Result = new RedirectToRouteResult(
               new RouteValueDictionary
               {
                    { "controller", "Home" },
                    { "action", "UnAuthorized" }
               });
        }
    }
}

將 Filter 加入 HomeController

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using Aspnet_MVC_AuthFilter_POC.Filters;

namespace Aspnet_MVC_AuthFilter_POC.Controllers
{
    [CustomAuthenticationFilter]
    public class HomeController : Controller
    {
        [CustomAuthorize("User", "SystemAdmin")]
        public ActionResult Index()
        {
            ...
        }

        [CustomAuthorize("Admin", "SystemAdmin")]
        public ActionResult About()
        {
            ...
        }

        [CustomAuthorize("SystemAdmin")]
        public ActionResult Contact()
        {
            ...
        }

        public ActionResult UnAuthorized()
        {
            ...
        }
    }
}

實作 _LoginPartial 顯示登入資訊及登出按鈕

/Views/_LoginPartial.cshtml

@if (!string.IsNullOrEmpty(Convert.ToString(Session["UserName"])))  
{  
    using (Html.BeginForm("LogOff", "Account", FormMethod.Post, new { id = "logoutForm", @class = "navbar-right" }))  
    {  
        @Html.AntiForgeryToken()  
        <ul class="nav navbar-nav navbar-right">  
            <li><a href="#">Welcome : @Session["UserName"]</a></li>  
            <li><a href="javascript:document.getElementById('logoutForm').submit()">Log off</a></li>  
        </ul>  
    }  
}  
else  
{  
    <ul class="nav navbar-nav navbar-right">  
        <li>@Html.ActionLink("Log in", "Login", "Account", routeValues: null, htmlAttributes: new { id = "loginLink" })</li>  
    </ul>  
}  

加入到 /Views/Shared/_Layout.cshtml

<div class="navbar-collapse collapse">
    <ul class="nav navbar-nav">
        <li>@Html.ActionLink("首頁", "Index", "Home")</li>
        <li>@Html.ActionLink("關於", "About", "Home")</li>
        <li>@Html.ActionLink("連絡人", "Contact", "Home")</li>
    </ul>
    @Html.Partial("_LoginPartial")
</div>

小結

未來可以再根據平時開發的需求,改採 Windows 驗證搭配 Filter Attribute,同時改採 SQLite 的方式儲存使用者資訊及權限。

目前應用的範例 Custom Authorization Fitler

/Filters/AuthorizeIdentityAttribute.cs

public class AuthorizeIdentityAttribute : AuthorizeAttribute
{
    private List<string> _permissionLists;
    private string _authGroupName;

    public AuthorizeIdentityAttribute(string authGroup = "default")
    {
        _authGroupName = authGroup;
    }

    public override void OnAuthorization(AuthorizationContext filterContext)
    {
        this._permissionLists = EmpAuthService.GetAuthEmpListByAuthGroupName(_authGroupName);
        if (!AuthorizeCore(filterContext.HttpContext))
        {
            base.HandleUnauthorizedRequest(filterContext);
        }
    }

    protected override bool AuthorizeCore(HttpContextBase httpContext)
    {
        var loginUser = httpContext.User.Identity.Name;
        if (this._permissionLists.Any(i => i == loginUser))
        {
            return true;
        }
        return false;
    }
}

/Controllers/HomeController.cs

[AuthorizeIdentity("default")] /* 所有 Controller 下的 Action 都受影響 */
public class HomeController : Controller
{
    public ActionResult Index()
    {
        ...
    }

    [OverrideAuthorization] /* 不受 AuthorizeIdentity 影響 */
    public ActionResult Details()
    {
        ...
    }
}

參考資料