首頁

目前文章總數:157 篇

  

最後更新:2024年 12月 07日

0041. .Net Core 使用Json Web Tokne(JWT)實現同一時間下單一帳戶登入

日期:2023年 06月 24日

標籤: C# Asp.NET Core Web Blazor Web JSON Web Tokens(JWT) Single sign-on(SSO)

摘要:C# 學習筆記


應用所需:1. Visual Studio 2022
     2. .net core Web專案 (Blazor server示範)
範例檔案:連結
解決問題:使用JWT後,避免同一時間下同個帳戶多處登入操作
補充說明:代碼結構延續前一版,可以先參考前一篇
應用層面:
基本介紹:本篇分為2大部分。
第一部分:範例專案說明
第二部分:Demo展示結果






第一部分:範例專案說明

Step 1:範例專案架構


1. 靜態設定 定義Sqlite資料庫、JWT的憑證
2. JWT業務邏輯 調整JWT產生器的共用方法、增加登出、登入時的註冊
3. JWT資料庫邏輯 對登入、登出時資料庫寫入、更新
4. 頁面行為 登入、登入後的顯示、登出邏輯調整




Step 2:靜態設定

以下是ConstUtil.cs定義了JWT的憑證,因為在系統登入後、登入時都會用到檢核,故抽出便於共用。


public class ConstUtil
{
    /// <summary>
    /// 發行者
    /// </summary>
    public const string Issuer = "JwtLoginIssuer";
    /// <summary>
    /// 加密金鑰
    /// </summary>
    public const string SignKey = "this_is_a_secure_key_with_length_greater_than_32";
    /// <summary>
    /// 使用者
    /// </summary>
    public const string Audience = "JwtLoginAudience";
}


初始化資料庫的方法,這邊是範例用Sqlite舉例,SqlLiteDbUtil.cs


/// <summary>
/// 本地Sqlite DataBase連線
/// </summary>
public static class SqlLiteDbUtil
{
    //1. 設定連線配置
    public const string DatabaseFileName = @"MyLogin.db";
    public const string ConnectionString = "Data Source=" + DatabaseFileName;

    static SqlLiteDbUtil()
    {
        Master = new SQLiteConnection(ConnectionString);
        CreateDatabaseIfNotExists();
    }

    public static IDbConnection Master { get; private set; }

    #region 2-1. 資料庫建構
    private static void CreateDatabaseIfNotExists()
    {
        if (!File.Exists(DatabaseFileName))
        {
            //Create Local Database
            Master.Open();

            //Initial Tables
            CreateDatabase();
        }

        //2-2. 建立登入表
        void CreateDatabase()
        {
            Master.Execute($@"
CREATE TABLE AccountToken (
    AccountName TEXT NOT NULL,
    Token TEXT NOT NULL,
	IsValid INT NOT NULL,
    LastDateTime DATETIME,
    PRIMARY KEY (AccountName)
);");
        }
        #endregion
    }
}




Step 3:JWT業務邏輯

主要分成三個區段

1. 登入時的頁面 登入後,會產生一個新的JWT,確保最新登入者的Token
2. 登入後的頁面 登入後,如果在頁面操作,會檢核當前Token是否有效,如果別的地方登入了,目前Token會失效
3. 登出時的行為 登出後,會註銷資料庫最新的Token

public class JsonWebTokenService
{
    private readonly ISqliteRepository _repository;
    public JsonWebTokenService(ISqliteRepository repository)
    {
        _repository = repository;
    }
    /// <summary>
    /// 產生JWT 
    /// </summary>
    public string GenerateToken(LoginModel user,
        int id, string type, string nickName)
    {
        var claims = new[] {
            new Claim(ClaimTypes.Name, user.Username),
            new Claim("type", type),
            new Claim("id", $@"{id}"),
            new Claim("nickname", nickName),
        };
        
        var jwtKey = Encoding.UTF8.GetBytes(ConstUtil.SignKey);
        var signingCredentials = new SigningCredentials(new SymmetricSecurityKey(jwtKey), SecurityAlgorithms.HmacSha256);
        
        var token = new JwtSecurityToken(
            issuer: ConstUtil.Issuer,     // 發行者:若解析驗證Token正確性時這個不同會視為驗證失敗
            audience: ConstUtil.Audience, // 使用者:若解析驗證Token正確性時這個不同會視為驗證失敗
            signingCredentials: signingCredentials,//簽名憑證:若解析驗證Token正確性時這個不同會視為驗證失敗
            claims: claims,      // 資料:可攜帶用戶資訊,像密碼類的不建議放進,如果被收集過多的token仍有可能被破解
            expires: DateTime.UtcNow.AddSeconds(120)//過期時間:如果超過此token會直接報廢
        );
        //1. 登入時的行為:註冊Token到資料庫中
        var newToken = new JwtSecurityTokenHandler().WriteToken(token);
        RegistToken(user.Username, newToken);
        return newToken;             
    }
    public void RegistToken(string userName, string token)
    {
        _repository.InsertOrUpdateToken(userName, token);
    }
    
    /// <summary>
    /// 2. 登入後的行為
    /// </summary>        
    public bool IsMatchToken(string currentToken)
    {            
        var tokenHandler = new JwtSecurityTokenHandler();
        var jwtToken = tokenHandler.ReadJwtToken(currentToken);
        //2-1. 檢核時間簽名憑證
        var jwtKey = Encoding.UTF8.GetBytes(ConstUtil.SignKey);                        
        tokenHandler.ValidateToken(currentToken, new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidAudience = ConstUtil.Audience,
            ValidIssuer = ConstUtil.Issuer,
            IssuerSigningKey = new SymmetricSecurityKey(jwtKey)
        }, out var validatedToken);
        //2-2. 檢核是否有用戶
        var UserName = jwtToken.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value;
        if (string.IsNullOrEmpty(UserName))
            return false;
        //2-3. 檢核資料庫
        if (currentToken != _repository.GetToken(UserName))
        {
            //如果不相同跳出
            return false;
        }
        return true;
    }

    //3. 登出時的行為
    public void SetInValid(string currentToken)
    {
        var tokenHandler = new JwtSecurityTokenHandler();
        var jwtToken = tokenHandler.ReadJwtToken(currentToken);
        var userName = jwtToken.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value;
        if (!string.IsNullOrEmpty(userName))
        {
            _repository.UpdateTokenInValid(userName, currentToken);
        }
    }
}




Step 4:JWT資料庫邏輯

主要分成三個區段

1. 紀錄令牌 登入後,會產生一個新的JWT,紀錄最新登入者對應帳號的Token
2. 取得令牌 登入後,如果在頁面操作,取得該帳號的Token提供匹配
3. 設成無效令牌 登出後,會註銷資料庫該帳號的Token

public class SqliteRepository : ISqliteRepository
    {
        /// <summary>
        /// 1. 紀錄令牌
        /// </summary>
        public void InsertOrUpdateToken(string account, string token)
        {
            var sql = $@"
INSERT OR REPLACE INTO AccountToken (AccountName, Token, IsValid, LastDateTime)
                             VALUES (@AccountName, @Token, @IsValid, datetime('now', 'localtime'))
;";
            SqlLiteDbUtil.Master.Execute(sql, new { AccountName = account, IsValid = 1, Token = token });
        }

        /// <summary>
        /// 2. 取得令牌
        /// </summary>
        public string GetToken(string account)
        {
            var sql = $@"
SELECT Token
  FROM AccountToken
 WHERE AccountName = @AccountName
;";
            return SqlLiteDbUtil.Master.QueryFirstOrDefault<string>(sql, new { AccountName = account });
        }

        /// <summary>
        /// 3. 設成無效令牌
        /// </summary>        
        public void UpdateTokenInValid(string account, string token)
        {
            var sql = $@"
UPDATE AccountToken
   SET IsValid = @IsValid
 WHERE AccountName= @AccountName
   AND Token = @Token
;";
            SqlLiteDbUtil.Master.Execute(sql, new { AccountName = account, IsValid = 1, Token = token });
        }
    }




Step 5:頁面行為-登入頁

登入頁 Login.razor,與前一版相同。


Step 6:頁面行為-登入後頁面

顯示畫面時會先驗證是否存在Token,若未匹配則視為登出


//1-1. token存在的情況下,檢查是否過期
if (jwtService.IsMatchToken(token))
{
    //1-2. 沒有過期則將用戶資料從Token取出,顯示在操作頁面上
    var tokenHandler = new JwtSecurityTokenHandler();
    var jwtToken = tokenHandler.ReadJwtToken(token);
    UserName = jwtToken.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value;
    Type = jwtToken.Claims.FirstOrDefault(c => c.Type == "type")?.Value;
    Id = jwtToken.Claims.FirstOrDefault(c => c.Type == "id")?.Value;
    NickName = jwtToken.Claims.FirstOrDefault(c => c.Type == "nickname")?.Value; 
    return true;
}
//1-3. token被註銷、或者過期視為不存在
return false;


登出時,強制將該帳號的Token註銷


//3. 登出行為
private void LogoutMethod()
{
    // 移除 Session 中的 JWT,導回首頁
    HttpContextAccessor.HttpContext.Session.Remove("JWT");
    jwtService.SetInValid(HttpContextAccessor?.HttpContext?.Session?.GetString("JWT"));
    Navigation.NavigateTo("/");
}






第二部分:Demo展示結果

Step 1:登入


我們需要用同個帳號進行登入 -> 輸入帳號a -> 登入


Step 2:開啟無痕


另一個頁面用無痕開啟(右邊) -> 輸入帳號a -> 登入


Step 3:登入後


無痕頁面(右邊)登入後,兩邊資訊一致


Step 4:模擬操作


左邊的原始頁面,進行重新整理(F5),可以發現被強制登出
右邊的無痕頁面,進行重新整理(F5),仍保留,這是因為系統只保留最後的JWT