承接上篇提到建立一個簡易的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.

          img

        • 輸入不符合 Email 的格式要顯示: The Email field is not a valid e-mail address.

          img

        • 輸入的 Email 無法通過要顯示: Invalid email

          img

    • 登入成功要顯示User Name

      • 一開始要檢查有無登入,有的話要顯示User Name

        img

    • 若登入後,關掉瀏覽器再重新進入頁面不用重新登入

  • Logout

    • 要顯示請進行登入

      img

    • 後端Session要清掉

  • API Authorization

    • 尚未登入就呼叫需授權之API,要顯示401 Unauthorize

      img

那在這個實作中,為了簡單化實作內容,User的資料用寫死的方式處理

所以這邊不會碰到 DB,而認證時,這邊也會先簡單處理

不會檢查密碼,只會進行 Email 的比對,唯一可以進行登入的 Email 為 test@auth.com

而授權API,就用Weather API來進行測試

基本處理流程

從架構上來說,大致處理流程如下

  1. 後端為了啟用 Authentication,必須在Startup.cs裡註冊相關的宣告

  2. 建立一個API Controller,用來處理 Authentication API 相關的邏輯

  3. 後端建立好 Authentication 機制後,後端就可以使用 [Authorize] Attribute 來管理API的授權

  4. 前端建立處理 Authentication 相關API 的來回機制

  5. 前端處理登入登出邏輯時,UI要能夠跟著連動

Back-End

調整 Startup.cs

開發環境 CORS

由於我們需要透過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
    }
}

設定Cookie-Based Authentication

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()
}

設定API ModelState錯誤訊息的回傳格式

由於預設的訊息很多,而且格式可能會不統一

因此這邊做個回傳格式的統一

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."
    ]
}

API Controller

接著在 Controllers/ 下,新增一個 AccountController.cs

在這邊建立4個API

  1. IsLogin: 當網頁第一次進入時,用來判斷user是否還有登入
  2. Login: 從前端拿取User輸入的Email,用來做登入的處理
  3. Logout: 用來登出,清掉session登入資訊用
  4. GetUserInfo: 用來讀取User的資料,像是姓名,當登入成功後,用來顯示在網頁上

IsLogin

這個功能比較單純,Call內建Function即可

[HttpGet]
public bool IsLogin()
{ 
    return User.Identity.IsAuthenticated;
}

Login

這邊是後端的一個重頭戲

基本上在認證登入時,當判斷帳號密碼通過後,妳可能會想存一些user的資訊進session中(就不用一直讀DB)

那就可以透過一些內建的物件(Claim)來存一些資訊,像是Email、Name、User Role....等等等

這樣就不需要自己宣告很多東西了

而設定完User的資訊後,就是設定authentication的一些功能,接著實際呼叫HttpContext.SignInAsync來設定session

完整的code請參考這邊

而概略流程的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");
}

Logout

[HttpPost]
public async Task<IActionResult> Logout()
{
    await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);

    return Ok("ok");
}

GetUserInfo

這邊使用 [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()

Front-End

Vuex Store

為了要讓登入相關資訊可以 Reactive,這邊使用vuex來儲存一些資訊

import { createStore } from 'vuex'

export default createStore({
  state: {
    isAuthenticated: false,
    claims: {},
  },
  mutations: {
  },
  actions: {
  },
  modules: {
  }
})

Authentication API

這邊將呼叫 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
}

Authentication UI

在Nav顯示User Name

為了在 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

現在我們終於可以來處理 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在此 歡迎參考看看啦

Reference

Use cookie authentication without ASP.NET Core Identity

如何實作沒有 ASP.NET Core Identity 的 Cookie-based 身分驗證機制

如何在 ASP.NET Core 3 使用 Token-based 身分驗證與授權 (JWT)