一、简介
前后端分离的站点一般都会用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; } } }
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/About能成功访问,证明匿名访问ok,
后面的登录,显示用户信息,退出登录也没问题,证明功能没问题,鉴权到这里就完成了。
这个自带的登录鉴权是利用cookie反解释为用户信息的,所以在站点多个服务器负载均衡也是没问题的(本人已验证nginx负载下没问题)。
四、.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; if (userId == null) { //没登录,直接结束 return Task.CompletedTask; } //登录成功时根据角色查出来这个用户的全部角色的菜单权限中的url地址,存到redis,这里实际是根据用户id从redis查询出来url地址。 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