最近把一個Asp .net core 2.0的項目遷移到Asp .net core 3.1,項目啟動的時候直接報錯: 看意思是缺少了一個authorization的中間件,這個項目在Asp.net core 2.0上是沒問題的。 startup是這樣註冊的: 查了文檔後發現3.0的示例代碼多了一個U ...
最近把一個Asp .net core 2.0的項目遷移到Asp .net core 3.1,項目啟動的時候直接報錯:
InvalidOperationException: Endpoint CoreAuthorization.Controllers.HomeController.Index (CoreAuthorization) contains authorization metadata, but a middleware was not found that supports authorization.
Configure your application startup by adding app.UseAuthorization() inside the call to Configure(..) in the application startup code. The call to app.UseAuthorization() must appear between app.UseRouting() and app.UseEndpoints(...).
Microsoft.AspNetCore.Routing.EndpointMiddleware.ThrowMissingAuthMiddlewareException(Endpoint endpoint)
看意思是缺少了一個authorization的中間件,這個項目在Asp.net core 2.0上是沒問題的。
startup是這樣註冊的:
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options =>
{
options.LoginPath = "/account/Login";
});
services.AddControllersWithViews();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/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.UseRouting();
app.UseAuthentication();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
}
}
查了文檔後發現3.0的示例代碼多了一個UseAuthorization,改成這樣就可以了:
app.UseRouting();
app.UseAuthentication();
//use授權中間件
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
});
看來Asp .net Core 3.1的認證跟授權又不太一樣了,只能繼續看文檔學習了。
UseAuthentication and UseAuthorization
先說一下Authentication跟Authorization的區別。這兩個單詞長的十分相似,而且還經常一起出現,很多時候容易搞混了。
- Authentication是認證,明確是你誰,確認是不是合法用戶。常用的認證方式有用戶名密碼認證。
- Authorization是授權,明確你是否有某個許可權。當用戶需要使用某個功能的時候,系統需要校驗用戶是否需要這個功能的許可權。
所以這兩個單詞是不同的概念,不同層次的東西。UseAuthorization在asp.net core 2.0中是沒有的。在3.0之後微軟明確的把授權功能提取到了Authorization中間件里,所以我們需要在UseAuthentication之後再次UseAuthorization。否則,當你使用授權功能比如使用[Authorize]屬性的時候系統就會報錯。
Authentication(認證)
認證的方案有很多,最常用的就是用戶名密碼認證,下麵演示下基於用戶名密碼的認證。新建一個MVC項目,添加AccountController:
[HttpPost]
public async Task<IActionResult> Login(
[FromForm]string userName, [FromForm]string password
)
{
//validate username password
...
var claims = new List<Claim>
{
new Claim(ClaimTypes.Name, userName),
new Claim(ClaimTypes.Role, "老師")
};
var claimsIdentity = new ClaimsIdentity(
claims, CookieAuthenticationDefaults.AuthenticationScheme);
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(claimsIdentity));
return Redirect("/");
}
public async Task<IActionResult> Logoff()
{
await HttpContext.SignOutAsync();
return Redirect("Login");
}
public IActionResult AccessDenied()
{
return Content("AccessDenied");
}
修改login.cshtml
@{
ViewData["Title"] = "Login Page";
}
<h1>
Login Page
</h1>
<form method="post">
<p>
用戶名: <input name="userName" value="administrator" />
</p>
<p>
密碼: <input name="password" value="123" />
</p>
<p>
<button>登錄</button>
</p>
</form>
從前臺傳入用戶名密碼後進行用戶名密碼校驗(示例代碼省略了密碼校驗)。如果合法,則把用戶的基本信息存到一個claim list里,並且指定cookie-base的認證存儲方案。最後調用SignInAsync把認證信息寫到cookie中。根據cookie的特性,接來下所有的http請求都會攜帶cookie,所以系統可以對接來下用戶發起的所有請求進行認證校驗。Claim有很多翻譯,個人覺得叫“聲明”比較好。一單認證成功,用戶的認證信息里就會攜帶一串Claim,其實就是用戶的一些信息,你可以存任何你覺得跟用戶相關的東西,比如用戶名,角色等,當然是常用的信息,不常用的信息建議在需要的時候查庫。調用HttpContext.SignOutAsync()方法清除用戶登認證信息。
Claims信息我們可以方便的獲取到:
@{
ViewData["Title"] = "Home Page";
}
<h2>
CoreAuthorization
</h2>
<p>
@Context.User.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value
</p>
<p>
角色:
@foreach (var claims in Context.User.Claims.Where(c => c.Type == System.Security.Claims.ClaimTypes.Role))
{
<span> @claims.Value </span>
}
</p>
<p>
<a href="/Student/index">/Student/index</a>
</p>
<p>
<a href="/Teacher/index">/Teacher/Index</a>
</p>
<p>
<a href="/Teacher/Edit">/Student/Edit</a>
</p>
<p>
<a href="/Account/Logoff">退出</a>
</p>
改一下home/Index頁面的html,把這些claim信息展示出來。
以上就是一個基於用戶名密碼以及cookie的認證方案。
Authorization(授權)
有了認證我們還需要授權。剛纔我們實現了用戶名密碼登錄認證,但是系統還是沒有任何管控,用戶可以隨意查庫任意頁面。現實中的系統往往都是某些頁面可以隨意查看,有些頁面則需要認證授權後才可以訪問。
AuthorizeAttribute
當我們希望一個頁面只有認證後才可以訪問,我們可以在相應的Controller或者Action上打上AuthorizeAttribute這個屬性。修改HomeController:
[Authorize]
public class HomeController : Controller
{
public IActionResult Index()
{
return View();
}
}
重新啟動網站,如果沒有登錄,訪問home/index的時候網站會跳轉到/account/AccessDenied。如果登錄後則可以正常訪問。AuthorizeAttribute預設授權校驗其實是把認證跟授權合為一體了,只要認證過,就認為有授權,這是也是最最簡單的授權模式。
基於角色的授權策略
顯然上面預設的授權並不能滿足我們開發系統的需要。AuthorizeAttribute還內置了基於Role(角色)的授權策略。
登錄的時候給認證信息加上角色的聲明:
[HttpPost]
public async Task<IActionResult> Login(
[FromForm]string userName,
[FromForm]string password
)
{
//validate username password
var claims = new List<Claim>
{
new Claim(ClaimTypes.Name, userName),
new Claim(ClaimTypes.Role, "老師"),
};
var claimsIdentity = new ClaimsIdentity(
claims, CookieAuthenticationDefaults.AuthenticationScheme);
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(claimsIdentity));
return Redirect("/");
}
新建一個TeacherController:
[Authorize(Roles = "老師")]
public class TeacherController : Controller
{
public IActionResult Index()
{
return Content("Teacher index");
}
}
給AuthorizeAttribute的屬性設置Roles=老師,表示只有老師角色的用戶才可以訪問。如果某個功能可以給多個角色訪問那麼可以給Roles設置多個角色,使用逗號進行分割。
[Authorize(Roles = "老師,校長")]
public class TeacherController : Controller
{
public IActionResult Index()
{
return Content("Teacher index");
}
}
這樣認證的用戶只要具有老師或者校長其中一個角色就可以訪問。
基於策略的授權
上面介紹了內置的基於角色的授權策略。如果現實中需要更複雜的授權方案,我們還可以自定義策略來支持。比如我們下麵定義一個策略:編輯功能只能姓王的老師可以訪問。
定義一個要求:
public class LastNamRequirement : IAuthorizationRequirement
{
public string LastName { get; set; }
}
IAuthorizationRequirement其實是一個空介面,僅僅用來標記,繼承這個介面就是一個要求。這是空介面,所以要求的定義比較寬鬆,想怎麼定義都可以,一般都是根據具體的需求設置一些屬性。比如上面的需求,本質上是根據老師的姓來決定是否授權通過,所以把姓作為一個屬性暴露出去,以便可以配置不同的姓。
除了要求,我們還需要實現一個AuthorizationHandler:
public class LastNameHandler : AuthorizationHandler<IAuthorizationRequirement>
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, IAuthorizationRequirement requirement)
{
var lastNameRequirement = requirement as LastNamRequirement;
if (lastNameRequirement == null)
{
return Task.CompletedTask;
}
var isTeacher = context.User.HasClaim((c) =>
{
return c.Type == System.Security.Claims.ClaimTypes.Role && c.Value == "老師";
});
var isWang = context.User.HasClaim((c) =>
{
return c.Type == "LastName" && c.Value == lastNameRequirement.LastName;
});
if (isTeacher && isWang)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
AuthorizationHandler是一個抽象類,繼承它後需要重寫其中的HandleRequirementAsync方法。這裡才是真正判斷是否授權成功的地方。要求(Requirement)跟用戶的聲明(Claim)信息會被傳到這方法里,然後我們根據這些信息進行判斷,如果符合授權就調用context.Succeed方法。這裡註意如果不符合請謹慎調用context.Failed方法,因為策略之間一般是OR的關係,這個策略不通過,可能有其他策略通過。
在ConfigureServices方法中添加策略跟註冊AuthorizationHandler到DI容器中:
services.AddSingleton<IAuthorizationHandler, LastNameHandler>();
services.AddAuthorization(options =>
{
options.AddPolicy("王老師", policy =>
policy.AddRequirements(new LastNamRequirement { LastName = "王" })
);
});
使用AddSingleton生命周期來註冊LastNameHandler,這個生命周期並不一定要單例,看情況而定。在AddAuthorization中添加一個策略叫"王老師"。這裡有個個人認為比較怪的地方,為什麼AuthorizationHandler不是在AddAuthorization方法中配置?而是僅僅註冊到容器中就可以開始工作了。如果有一個需求,僅僅是需要自己調用一下自定義的AuthorizationHandler,而並不想它真正參與授權。這樣的話就不能使用DI的方式來獲取實例了,因為一註冊進去就會參與授權的校驗了。
在TeacherController下添加一個 Edit Action:
[Authorize(Policy="王老師")]
public IActionResult Edit()
{
return Content("Edit success");
}
給AuthorizeAttribute的Policy設置為“王老師”。
修改Login方法添加一個姓的聲明:
[HttpPost]
public async Task<IActionResult> Login(
[FromForm]string userName,
[FromForm]string password
)
{
//validate username password
var claims = new List<Claim>
{
new Claim(ClaimTypes.Name, userName),
new Claim(ClaimTypes.Role, "老師"),
new Claim("LastName", "王"),
};
var claimsIdentity = new ClaimsIdentity(
claims, CookieAuthenticationDefaults.AuthenticationScheme);
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(claimsIdentity));
return Redirect("/");
}
運行一下程式,訪問一下/teacher/edit,可以看到訪問成功了。如果修改Login方法,修改LastName的聲明為其他值,則訪問會拒絕。
使用泛型Func方法配置策略
如果你的策略比較簡單,其實還有個更簡單的方法來配置,就是在AddAuthorization方法內直接使用一個Func來配置策略。
使用Func來配置一個女老師的策略:
options.AddPolicy("女老師", policy =>
policy.RequireAssertion((context) =>
{
var isTeacher = context.User.HasClaim((c) =>
{
return c.Type == System.Security.Claims.ClaimTypes.Role && c.Value == "老師";
});
var isFemale = context.User.HasClaim((c) =>
{
return c.Type == "Sex" && c.Value == "女";
});
return isTeacher && isFemale;
}
)
);
總結
- Authentication跟Authorization是兩個不同的概念。Authentication是指認證,認證用戶的身份;Authorization是授權,判斷是否有某個功能的許可權。
- Authorization內置了基於角色的授權策略。
- 可以使用自定義AuthorizationHandler跟Func的方式來實現自定義策略。
吐槽
關於認證跟授權微軟為我們考慮了很多很多,包括identityserver,基本上能想到的都有了,什麼oauth,openid,jwt等等。其實本人是不太喜歡用的。雖然微軟都給你寫好了,考慮很周到,但是學習跟Trouble shooting都是要成本的。其實使用中間件、過濾器再配合redis等組件,很容易自己實現一套授權認證方案,自由度也更高,有問題修起來也更快。自己實現一下也可以更深入的瞭解某項的技術,比如jwt是如果工作的,oauth是如何工作的,這樣其實更有意義。