承接上篇提到建立一個簡易的Vue3.0 + .net core3.0 Template
其實Microsoft提供的SPA Template中,是有包含authentication,登入認證這塊的
但一樣,就是沒有Vue的版本 (༎ຶ L_ ༎ຶ )
那說到 Authentication 這塊,其實實作方式滿多種的:
Cookie-Based
算是比較傳統的方式,好實作,前端cookie存個session id,後端session進行一些資料的紀錄
Token-Based
簡單來說,利用JWT,做到類似發號碼牌的方式來實作認證,跟Cookie-Based
相比,後端session不用進行身分資料的紀錄
Identity Server
Identity Server 是 Microsoft 基於 OAuth 還有 OIDC(Open ID Connection) 規格實作
企圖用它來完成各式各樣的認證方式,像是 Facebook 認證、Active Directory 認證
API 權限管理、SSO(Single-Sign-Oo) ...,相當多的功能
那這邊將基於 Cookie-Based 來進行 Authentication 的實作
Cookie-Based Authentication 算滿好實作的
但缺點是 Load Balance 時會需要額外做設定
像使用 Session Server 來儲存 Session 裡的資料(Redis Server、DB...)
或是 Load Balance 時,讓特定人之Request固定打到某台server ...
關於 Authentication,以下是這次要實作的相關功能說明
Login
進行登入認證
只進行 Email
的檢查:
輸入空白要顯示錯誤訊息: The Email field is required.
輸入不符合 Email 的格式要顯示: The Email field is not a valid e-mail address.
輸入的 Email 無法通過要顯示: Invalid email
登入成功要顯示User Name
一開始要檢查有無登入,有的話要顯示User Name
若登入後,關掉瀏覽器再重新進入頁面不用重新登入
Logout
要顯示請進行登入
後端Session要清掉
API Authorization
尚未登入就呼叫需授權之API,要顯示401 Unauthorize
那在這個實作中,為了簡單化實作內容,User的資料用寫死的方式處理
所以這邊不會碰到 DB,而認證時,這邊也會先簡單處理
不會檢查密碼,只會進行 Email
的比對,唯一可以進行登入的 Email 為 test@auth.com
而授權API,就用Weather API來進行測試
從架構上來說,大致處理流程如下
後端為了啟用 Authentication,必須在Startup.cs
裡註冊相關的宣告
建立一個API Controller,用來處理 Authentication API 相關的邏輯
後端建立好 Authentication 機制後,後端就可以使用 [Authorize]
Attribute 來管理API的授權
前端建立處理 Authentication 相關API 的來回機制
前端處理登入登出邏輯時,UI要能夠跟著連動
由於我們需要透過http header中的set-cookie來將session id自動放到cookie中
這時若Vue這邊是額外起一個server的話,那就會因為CORS的關係,導致cookie設不了
因此這邊我們可以在開發環境中,讓CORS先通過
public void ConfigureServices(IServiceCollection services)
{
// Add service and create Policy with options
services.AddCors(options =>{});
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
// Add global cors policy
app.UseCors(x => x
.AllowAnyMethod()
.AllowAnyHeader()
.SetIsOriginAllowed(origin => true) // allow any origin
.AllowCredentials()); // allow credentials
}
}
public void ConfigureServices(IServiceCollection services)
{
// Add cookie-based authentication
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie()
}
由於預設行為,在未授權的情況下使用API,會把妳導到登入頁
這邊我希望他回傳個401 Unauthorized就好,所以上述的Code要再加一些設定
public void ConfigureServices(IServiceCollection services)
{
// Add cookie-based authentication
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
// Return 401 instead of 302 when accessing resouce without authorization
// (Default behavior will redirect to login url)
options.Events.OnRedirectToLogin = context =>
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
};
});
}
接著調整 Configure
這邊要注意順序,app.UseRouting
,app.UseAuthentication
,app.UseAuthorization
,app.UseEndpoints
這4個的順序是不能顛倒的,不然Compiler不會報錯
(然後實際運作時,就會發現Authentication沒作用 lol)
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// Routing
app.UseRouting();
// Authentication
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints()
}
由於預設的訊息很多,而且格式可能會不統一
因此這邊做個回傳格式的統一
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers()
.ConfigureApiBehaviorOptions(options => {
// Format return message from ModelState
options.InvalidModelStateResponseFactory = actionContext =>
{
var modelState = actionContext.ModelState.Values;
return new BadRequestObjectResult(actionContext.ModelState);
};
});
}
當 ModelState.IsValid == false
時,回傳格式統一為
{
"Email":[
"The Email field is required.",
"The Email field is not a valid e-mail address."
]
}
接著在 Controllers/
下,新增一個 AccountController.cs
在這邊建立4個API
這個功能比較單純,Call內建Function即可
[HttpGet]
public bool IsLogin()
{
return User.Identity.IsAuthenticated;
}
這邊是後端的一個重頭戲
基本上在認證登入時,當判斷帳號密碼通過後,妳可能會想存一些user的資訊進session中(就不用一直讀DB)
那就可以透過一些內建的物件(Claim)來存一些資訊,像是Email
、Name
、User Role
....等等等
這樣就不需要自己宣告很多東西了
而設定完User的資訊後,就是設定authentication的一些功能,接著實際呼叫HttpContext.SignInAsync
來設定session
而概略流程的Code如下:
[HttpPost]
public async Task<IActionResult> Login([FromBody] AuthenticationUser authenticationUser)
{
...
var claims = new List<Claim>
{
// 設定 User 的一些資訊
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.Name, user.Name),
new Claim(ClaimTypes.Role, "Administrator"),
};
var claimsIdentity = new ClaimsIdentity(
claims, CookieAuthenticationDefaults.AuthenticationScheme);
// 設定 Authentication
var authProperties = new AuthenticationProperties
{
// AllowRefresh = true,
// Refreshing the authentication session should be allowed.
// ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(10),
// The time at which the authentication ticket expires. A
// value set here overrides the ExpireTimeSpan option of
// CookieAuthenticationOptions set with AddCookie.
// IsPersistent = true,
// Whether the authentication session is persisted across
// multiple requests. When used with cookies, controls
// whether the cookie's lifetime is absolute (matching the
// lifetime of the authentication ticket) or session-based.
//IssuedUtc = <DateTimeOffset>,
// The time at which the authentication ticket was issued.
//RedirectUri = <string>
// The full path or absolute URI to be used as an http
// redirect response value.
};
// 將user登入的資訊寫到session中
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(claimsIdentity),
authProperties);
return Ok("ok");
}
[HttpPost]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Ok("ok");
}
這邊使用 [Authorize]
,需要登入後才能呼叫此API
[HttpGet]
[Authorize]
public UserInfo GetUserInfo()
{
var userInfo = new UserInfo(User.Claims.ToList());
return userInfo;
}
// Models/UserInfo.cs
public class UserInfo
{
//public List<SimpleClaim> Claims { get; set; } = new List<SimpleClaim>();
public Dictionary<string, string> Claims { get; set; } = new Dictionary<string, string>();
public UserInfo(IEnumerable<Claim> claims)
{
foreach (var claim in claims)
{
string type = claim.Type.Split("/").Last();
string value = claim.Value;
this.Claims[type] = value;
}
}
}
[Authorize]
Attribute經過上面的設定,現在我們已經可以使用 [Authorize]
Attribute
接著在 Weather API上,加上 [Authorize]
// Controllers/WeatherForecastController.cs
[HttpGet]
[Authorize]
public IEnumerable<WeatherForecast> Get()
為了要讓登入相關資訊可以 Reactive,這邊使用vuex來儲存一些資訊
import { createStore } from 'vuex'
export default createStore({
state: {
isAuthenticated: false,
claims: {},
},
mutations: {
},
actions: {
},
modules: {
}
})
這邊將呼叫 Authentication API 的相關function都整理起來
像是登入成功後去更新store之類的動作
並且設定 axios.defaults.withCredentials = true
來讓 cookie 中的資訊能被自動在http header中帶到後端
import axios from 'axios'
axios.defaults.withCredentials = true
interface ILogin{
email: string;
}
function IsLogin(){
return axios.get(process.env.VUE_APP_SERVER_URL + 'api/account/islogin')
}
function GetUserInfo(store: any){
return axios.get(process.env.VUE_APP_SERVER_URL + 'api/account/getuserinfo')
.then( response =>{
store.state.claims = response.data.claims
console.log(response.data)
})
}
function Login(data: ILogin, store: any){
return axios.post(process.env.VUE_APP_SERVER_URL + 'api/account/login', data)
.then(()=>{
store.state.isAuthenticated = true
GetUserInfo(store)
})
}
function Logout(store: any){
return axios.post(process.env.VUE_APP_SERVER_URL + 'api/account/logout')
.then(()=>{
store.state.isAuthenticated = false
})
}
export {
ILogin,
IsLogin,
Login,
Logout,
GetUserInfo
}
為了在 Navigation 的UI上,讓右上角的文字可以隨著登入狀態變化而改動
這邊將相關邏輯先寫在了 App.vue
上
<script lang="ts">
import { defineComponent, computed } from 'vue'
import { useStore } from 'vuex'
import { IsLogin, GetUserInfo } from '@/api/authentication.ts'
export default defineComponent({
name: 'App',
setup(){
const store = useStore()
const unauthInfo = "You are not log in"
const authInfo = computed(()=>{
return `Hi, ${store.state.claims.name}! You are now log in!`
})
const authenticationInfo = computed(() =>{
return store.state.isAuthenticated ? authInfo.value : unauthInfo
})
IsLogin().then( response => {
store.state.isAuthenticated = Boolean(response.data)
if(store.state.isAuthenticated){
GetUserInfo(store)
}
})
return {
authenticationInfo
}
}
});
</script>
登入頁的組件做在 TestAuthentication.vue 上
這頁主要要實作的邏輯有:
而在登入失敗時,將透過400的status code,來處理從後端帶來的錯誤訊息
現在我們終於可以來處理 Weather API 了!
完整的 code 在 TestWeatherAPI.vue
判斷授權的部分,這邊靠401的status code來判斷
當有登入時呼叫,可以得到 weather 的資訊
而若沒有授權,會出現 401 Unauthorize 的描述
axios.get(process.env.VUE_APP_SERVER_URL + 'WeatherForecast').then(response =>{
weather.value = response.data
console.log(weather)
}, error => {
console.log(error)
console.log(error.response.status)
if( error.response.status == 401 ){
weather.value = "401 Unauthorize, please log in to access Weather API"
}
})
雖然實作了登入授權的部分,但其實還有許多地方是可以改造的
像是連DB拿資料、或授權時用 user role 來進行判斷...
但這邊就是簡單實作一個 Cookie-Based Authentication
完整的Code在此 歡迎參考看看啦
Use cookie authentication without ASP.NET Core Identity