.NET Core中的鑑權授權正確方式(.NET5)

語言: CN / TW / HK

一、簡介

前後端分離的站點一般都會用jwt或IdentityServer4之類的生成token的方式進行登入鑑權。這裡要說的是小專案沒有做前後端分離的時站點登入授權的正確方式。

二、傳統的授權方式

這裡說一下傳統授權方式,傳統授權方式用session或cookies來完成。

1.在請求某個Action之前去做校驗,驗證當前操作者是否登入過,登入過就有許可權

2.如果沒有許可權就跳轉到登入頁中去

3.傳統登入授權用的AOP-Filter:ActionFilter。

具體實現為:

1.增加一個類CurrentUser.cs 儲存使用者登入資訊

 /// <summary>
    /// 登入使用者的資訊
    /// </summary>
    public class CurrentUser
    {
        /// <summary>
        /// 使用者Id
        /// </summary>
        public int Id { get; set; }
        /// <summary>
        /// 使用者名稱稱
        /// </summary>
        public string Name { get; set; }
        /// <summary>
        /// 賬號
        /// </summary>
        public string Account { get; set; }
    }

2.建一個Cookice/Session幫助類CookieSessionHelper.cs

 public static class CookieSessionHelper
    {
        public static void SetCookies(this HttpContext httpContext, string key, string value, int minutes = 30)
        {
            httpContext.Response.Cookies.Append(key, value, new CookieOptions
            {
                Expires = DateTime.Now.AddMinutes(minutes)
            });
        }
        public static void DeleteCookies(this HttpContext httpContext, string key)
        {
            httpContext.Response.Cookies.Delete(key);
        }

        public static string GetCookiesValue(this HttpContext httpContext, string key)
        {
            httpContext.Request.Cookies.TryGetValue(key, out string value);
            return value;
        }
        public static CurrentUser GetCurrentUserByCookie(this HttpContext httpContext)
        {
            httpContext.Request.Cookies.TryGetValue("CurrentUser", out string sUser);
            if (sUser == null)
            {
                return null;
            }
            else
            {
                CurrentUser currentUser = Newtonsoft.Json.JsonConvert.DeserializeObject<CurrentUser>(sUser);
                return currentUser;
            }
        }

        public static CurrentUser GetCurrentUserBySession(this HttpContext context)
        {
            string sUser = context.Session.GetString("CurrentUser");
            if (sUser == null)
            {
                return null;
            }
            else
            {
                CurrentUser currentUser = Newtonsoft.Json.JsonConvert.DeserializeObject<CurrentUser>(sUser);
                return currentUser;
            }
        }
    }

View Code

3.建一個登入控制器AccountController.cs

public class AccountController : Controller
    {
        //登入頁面
        public IActionResult Login()
        {
            return View();
        }
        //登入提交
        [HttpPost]
        public IActionResult LoginSub(IFormCollection fromData)
        {
            string userName = fromData["userName"].ToString();
            string passWord = fromData["password"].ToString();
            //真正寫法是讀資料庫驗證
            if (userName == "test" && passWord == "123456")
            {
                #region 傳統session/cookies
                //登入成功,記錄使用者登入資訊
                CurrentUser currentUser = new CurrentUser()
                {
                    Id = 123,
                    Name = "測試賬號",
                    Account = userName
                };

                //寫sessin
               // HttpContext.Session.SetString("CurrentUser", JsonConvert.SerializeObject(currentUser));
                //寫cookies
                HttpContext.SetCookies("CurrentUser", JsonConvert.SerializeObject(currentUser));
                #endregion

                //跳轉到首頁
                return RedirectToAction("Index", "Home");

            }
            else
            {
                TempData["err"] = "賬號或密碼不正確";
                //賬號密碼不對,跳回登入頁
                return RedirectToAction("Login", "Account");
            }
        }
        /// <summary>
        /// 退出登入
        /// </summary>
        /// <returns></returns>
        public IActionResult LogOut()
        {
            HttpContext.DeleteCookies("CurrentUser");
            //Session方式
            // HttpContext.Session.Remove("CurrentUser");
            return RedirectToAction("Login", "Account");
        }
    }

4.登入頁Login.cshtml 內容

<form action="/Account/LoginSub" method="post">
    <div>
        賬號:<input type="text" name="userName" />
    </div>
    <div>
        賬號:<input type="password" name="passWord" />
    </div>
    <div>
       <input type="submit" value="登入" /> <span style="color:#ff0000">@TempData["err"]</span>
    </div>
</form>

5.建一個登入成功跳轉到主頁控制器HomeController.cs

public class HomeController : Controller
    {
        public IActionResult Index()
        {
            //從cookie獲取使用者資訊
             CurrentUser user = HttpContext.GetCurrentUserByCookie();
            //CurrentUser user = HttpContext.GetCurrentUserBySession();
            return View(user);
        }
    }

6.頁面 Index.cshtml

@{
    ViewData["Title"] = "Index";
}

@model SessionAuthorized.Demo.Models.CurrentUser

<h1>歡迎 @Model.Name 來到主頁</h1>
<div><a href="/Account/Logout">退出登入</a></div>

7.增加鑑權過濾器MyActionAuthrizaFilterAttribute.cs,實現IActinFilter,在OnActionExecuting中寫鑑權邏輯

 public class MyActionAuthrizaFilterAttribute : Attribute, IActionFilter
    {
        public void OnActionExecuted(ActionExecutedContext context)
        {
            //throw new NotImplementedException();
        }
        /// <summary>
        /// 進入action前
        /// </summary>
        /// <param name="context"></param>
        public void OnActionExecuting(ActionExecutingContext context)
        {
            //throw new NotImplementedException();
            Console.WriteLine("開始驗證許可權...");
           // CurrentUser currentUser = context.HttpContext.GetCurrentUserBySession();
            CurrentUser currentUser = context.HttpContext.GetCurrentUserByCookie();
            if (currentUser == null)
            {
                Console.WriteLine("沒有許可權...");
                if (this.IsAjaxRequest(context.HttpContext.Request))
                {
                    context.Result = new JsonResult(new
                    {
                        Success = false,
                        Message = "沒有許可權"
                    });
                }
                context.Result = new RedirectResult("/Account/Login");
          return;
            }
            Console.WriteLine("許可權驗證成功...");
        }
        private bool IsAjaxRequest(HttpRequest request)
        {
            string header = request.Headers["X-Requested-With"];
            return "XMLHttpRequest".Equals(header);
        }
    }

在需要鑑權的控制器或方法上加上這個Filter即可完成鑑權,這裡在主頁中加入鑑權,登入成功的使用者才能訪問

8.如果要用Session,還要在startup.cs中加入Session

 public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllersWithViews();
            services.AddSession();

        }
 public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Error");
                // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
                app.UseHsts();
            }

            app.UseHttpsRedirection();
            app.UseStaticFiles();
            app.UseSession();
            app.UseRouting();

            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapDefaultControllerRoute();
            });
        }

到這裡,傳統的鑑權就完成了,下面驗證一下效果。

三、 .NET5中正確的鑑權方式

傳統的授權方式是通過Action Filter(before)來完成的,上圖.Net Core的filter順序可以發現,Action filter(befre)之前還有很多個filter,如果可以在前把鑑權做了,就能少跑了幾步冤枉路,所以,正確的鑑權應該是在 Authorization filter 中做,Authorization filter是.NET5裡面專門做鑑權授權用的。

怎麼做呢,鑑權授權通過中介軟體支援。

1.在staup.cs的Configure方法裡面的app.UseRouting();之後,在app.UseEndpoints()之前,增加鑑權授權;

 public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Error");
                // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
                app.UseHsts();
            }

            app.UseHttpsRedirection();
            app.UseStaticFiles();
            app.UseSession();
            app.UseRouting();
            app.UseAuthentication();//檢測使用者是否登入
            app.UseAuthorization(); //授權,檢測有沒有許可權,是否能夠訪問功能
           
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapDefaultControllerRoute();
            });
        }

2.在ConfigureServices中增加

 public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllersWithViews();
            //services.AddSession(); 傳統鑑權

            services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
                .AddCookie(options => {
                    options.LoginPath = new PathString("/Account/Login");//沒登入跳到這個路徑
                });

        }

3.標記哪些控制器或方法需要登入認證,在控制器或方法頭標記特性 [ Authorize] ,如果裡面有方法不需要登入驗證的,加上匿名訪問標識  [AllowAnonymousAttribute]

// [MyActionAuthrizaFilterAttribute] 傳統授權
   [Authorize]
    public class HomeController : Controller
    {
        public IActionResult Index()
        {
            //從cookie獲取使用者資訊
            // CurrentUser user = HttpContext.GetCurrentUserByCookie();
            //CurrentUser user = HttpContext.GetCurrentUserBySession();

            var userInfo = HttpContext.User;
            CurrentUser user = new CurrentUser()
            {
                Id = Convert.ToInt32(userInfo.FindFirst("id").Value),
                Name = userInfo.Identity.Name,
                Account=userInfo.FindFirst("account").Value
            };
            return View(user);
        }

        /// <summary>
        /// 無需登入,匿名訪問
        /// </summary>
        /// <returns></returns>
        [AllowAnonymousAttribute]
        public IActionResult About()
        {
            return Content("歡迎來到關於頁面");
        }
    }

4.登入處AccountController.cs的程式碼

 public class AccountController : Controller
    {
        //登入頁面
        public IActionResult Login()
        {
            return View();
        }
        //登入提交
        [HttpPost]
        public IActionResult LoginSub(IFormCollection fromData)
        {
            string userName = fromData["userName"].ToString();
            string passWord = fromData["password"].ToString();
            //真正寫法是讀資料庫驗證
            if (userName == "test" && passWord == "123456")
            {
                #region 傳統session/cookies
                //登入成功,記錄使用者登入資訊
                //CurrentUser currentUser = new CurrentUser()
                //{
                //    Id = 123,
                //    Name = "測試賬號",
                //    Account = userName
                //};

                //寫sessin
                // HttpContext.Session.SetString("CurrentUser", JsonConvert.SerializeObject(currentUser));
                //寫cookies
                //HttpContext.SetCookies("CurrentUser", JsonConvert.SerializeObject(currentUser));
                #endregion

                //使用者角色列表,實際操作是讀資料庫
                var roleList = new List<string>()
                {
                    "Admin",
                    "Test"
                };
                var claims = new List<Claim>() //用Claim儲存使用者資訊
                {
                    new Claim(ClaimTypes.Name,"測試賬號"),
                    new Claim("id","1"),
                    new Claim("account",userName),//...可以增加任意資訊
                };
                //填充角色
                foreach(var role in roleList)
                {
                    claims.Add(new Claim(ClaimTypes.Role, role));
                }
                //把使用者資訊裝到ClaimsPrincipal
                ClaimsPrincipal claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims, "Customer"));
                //登入,把使用者資訊寫入到cookie
                HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, claimsPrincipal,
                    new AuthenticationProperties
                    {
                        ExpiresUtc = DateTime.Now.AddMinutes(30)//過期時間30分鐘
                    }).Wait();

                //跳轉到首頁
                return RedirectToAction("Index", "Home");

            }
            else
            {
                TempData["err"] = "賬號或密碼不正確";
                //賬號密碼不對,跳回登入頁
                return RedirectToAction("Login", "Account");
            }
        }
        /// <summary>
        /// 退出登入
        /// </summary>
        /// <returns></returns>
        public IActionResult LogOut()
        {
            // HttpContext.DeleteCookies("CurrentUser");
            //Session方式
            // HttpContext.Session.Remove("CurrentUser");

            HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
            return RedirectToAction("Login", "Account");
        }
    }

5.驗證結果:

可以看到,一開始沒登入狀態,訪問/Home/Index會跳轉到登入頁面,訪問/Home/Index能成功訪問,證明匿名訪問ok,

後面的登入,顯示使用者資訊,退出登入也沒問題,證明功能沒問題,鑑權到這裡就完成了。

四、.NET5中角色授權

上面的claims中已經記錄了使用者角色,這個角色就可以用來做授權了。

在startup.cs中修改沒許可權時跳轉頁面路徑

public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllersWithViews();
            //services.AddSession(); 傳統鑑權

            services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
                .AddCookie(options => {
                    options.LoginPath = new PathString("/Account/Login");//沒登入跳到這個路徑
                    options.AccessDeniedPath = new PathString("/Account/AccessDenied");//沒許可權跳到這個路徑
                });

        }

AccountController.cs增加方法

  public IActionResult AccessDenied()
        {
            return View();
        }

檢視內容

沒有許可權訪問-401

1.單個角色訪問許可權

在方法頭加上特性  [Authorize(Roles ="角色程式碼")]

在HomeController.cs中增加一個方法

     /// <summary>
        /// 角色為Admin能訪問
        /// </summary>
        /// <returns></returns>
        [Authorize(Roles ="Admin")]
        public IActionResult roleData1() {
            return Content("Admin能訪問");
        }

驗證。

開始角色為

訪問roleData1資料:

訪問成功,然後把角色Admin去掉

 var roleList = new List<string>()
                {
                    //"Admin",
                    "Test"
                };

重新登入,在訪問rleData1資料:

訪問不成功,跳轉到預設的沒許可權的頁面了。

2.“多個角色包含一個”許可權

    [Authorize(Roles = "Admin,Test")]//多個角色用逗號隔開,角色包含有其中一個就能訪問
        public IActionResult roleData2()
        {
            return Content("roleData2訪問成功");
        }

3.“多個角色組合”許可權

     /// <summary>
        /// 同時擁有標記的全部角色才能訪問
        /// </summary>
        /// <returns></returns>
        [Authorize(Roles = "Admin")]
        [Authorize(Roles = "Test")]
        public IActionResult roleData3()
        {
            return Content("roleData3訪問成功");
        }

五、自定義策略授權

上面的角色授權的缺點在哪裡呢,最大的缺點就是角色要提前寫死到方法上,如果要修改只能改程式碼,明顯很麻煩,實際專案中許可權都是根據配置修改的,

所以就要用到自定義策略授權了。

第一步:

增加一個CustomAuthorizatinRequirement.cs,要求實現介面:IAuthorizationRequirement

  /// <summary>
    /// 策略授權引數
    /// </summary>
    public class CustomAuthorizationRequirement: IAuthorizationRequirement
    {
        /// <summary>
        /// 
        /// </summary>
        public CustomAuthorizationRequirement(string policyname)
        {
            this.Name = policyname;
        }

        public string Name { get; set; }
    }

增加CustomAuthorizationHandler.cs------專門做檢驗邏輯的;要求繼承自AuthorizationHandler<>泛型抽象類;

   /// <summary>
    /// 自定義授權策略
    /// </summary>
    public class CustomAuthorizationHandler: AuthorizationHandler<CustomAuthorizationRequirement>
    {
        public CustomAuthorizationHandler()
        {

        }
       protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, CustomAuthorizationRequirement requirement)
        {
           
            bool flag = false;
            if (requirement.Name == "Policy01")
            {
                Console.WriteLine("進入自定義策略授權01...");
                ///策略1的邏輯
            }

            if (requirement.Name == "Policy02")
            {
                Console.WriteLine("進入自定義策略授權02...");
                ///策略2的邏輯
            }

            if(flag)
            {
                context.Succeed(requirement); //驗證通過了
            }

            return Task.CompletedTask; //驗證不同過
        }
    }

第二步,讓自定義的邏輯生效。

starup.cs的ConfigureServices方法中註冊進來

 public void ConfigureServices(IServiceCollection services)
        {
            services.AddControllersWithViews();
            //services.AddSession(); 傳統鑑權
            services.AddSingleton<IAuthorizationHandler, CustomAuthorizationHandler>();

            services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
                .AddCookie(options =>
                {
                    options.LoginPath = new PathString("/Account/Login");//沒登入跳到這個路徑
                    options.AccessDeniedPath = new PathString("/Account/AccessDenied");//沒許可權跳到這個路徑
                });
            services.AddAuthorization(optins =>
            {
                //增加授權策略
                optins.AddPolicy("customPolicy", polic =>
                {
                    polic.AddRequirements(new CustomAuthorizationRequirement("Policy01")
                       // ,new CustomAuthorizationRequirement("Policy02")
                        );
                });
            });

        }

第三步,把要進授權策略的控制器或方法增加標識

HomeContrller.cs增加測試方法

       /// <summary>
        /// 進入授權策略
        /// </summary>
        /// <returns></returns>
        [Authorize(policy: "customPolicy")]
        public IActionResult roleData4()
        {
            return Content("自定義授權策略");
        }

訪問roleData4,看是否進到自定義授權策略邏輯

可以看到自定義授權策略生效了,授權策略就可以在這裡做了,下面加上授權邏輯。

我這裡的許可權用路徑和角色關聯授權,加上授權邏輯後的校驗程式碼。

/// <summary>
    /// 自定義授權策略
    /// </summary>
    public class CustomAuthorizationHandler : AuthorizationHandler<CustomAuthorizationRequirement>
    {
        public CustomAuthorizationHandler()
        {

        }
        protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, CustomAuthorizationRequirement requirement)
        {
            bool flag = false;

            //把context轉換到httpConext,方便取上下文
            HttpContext httpContext = context.Resource as HttpContext;
            string path = httpContext.Request.Path;//當前訪問路徑,例:"/Home/roleData4"

            var user = httpContext.User;
            //使用者id
            string userId = user.FindFirst("id").Value;

            //登入成功時根據角色查出來這個使用者的許可權存到redis,這裡實際是根據使用者id從redis查詢出來
            List<string> paths = new List<string>()
            {
                "/Home/roleData4",
                "/Home/roleData3"
            };

            if (requirement.Name == "Policy01")
            {
                Console.WriteLine("進入自定義策略授權01...");
                ///策略1的邏輯
                if (paths.Contains(path))
                {
                    flag = true;
                }
            }

            if (requirement.Name == "Policy02")
            {
                Console.WriteLine("進入自定義策略授權02...");
                ///策略2的邏輯
            }

            if (flag)
            {
                context.Succeed(requirement); //驗證通過了
            }

            return Task.CompletedTask; //驗證不同過
        }
    }

加上邏輯後再訪問。

訪問成功,自定義授權策略完成。

原始碼: https://github.com/weixiaolong325/SessionAuthorized.Demo