接下來逐步完善我們的web server。
寫過C#的話應該知道一般而言靜態檔案放在wwwroot裡,有時候我們後端需要提供一些檔案讓用戶端下載,這時候就放在靜態網站中,靜態網頁伺服器(static web server)不是指網頁不會動,而是說網站不執行程式。
其實前端SPA打包完就是靜態的檔案,放到web server裡,在使用的時候,由客戶端下載相關的html/JavaScript/css等資料到瀏覽器端,再由Browser進行解析,所以執行JavaScript是Browser執行,不是後端的站台執行。因此前端(不考慮SSR)就只是單純的一堆靜態檔案,可能會有一個html檔加一堆的js檔,所以才叫Single Page,這個Page就是指只有一個html網頁檔案。
剛剛講的是網站,是從站台服務角度。如果換成從使用者角色,能與使用者互動的是動態網頁(Dynamic web page),這邊的互動指的不是點點超連結,或是捲軸往上/往下拉(那個是瀏覽器提供的),而是說是否能依據不同的使用者操作,做出不同的回應,白話版就是要寫程式來回應使用者的操作,可能是加入購物車,或是依用戶輸入的數值計算BMI等,這些都需要寫程式來判斷。動態網頁的程式碼可以寫在前端(Vue, Angular, React, VanillaJS),或是寫在後端(主流語言包括C#, Java, Python, Ruby, ...等都有相關的Web框架)。
前後端分離指的可以分別開發,佈署,中間使用協定好的通訊機制(API)進行溝通,既然都叫前後端分離,理論上應該可以獨立開發,但有些前端的開發人員,還能還是時常需要等後端(開DB -> 寫Model -> 寫BL -> 寫API)完成後才提供給前端,或是做好後端又一直改來改去的,這樣前後端分離(就開發流程上)的效益就沒有被體現,這個影片DevOps 潮流下的 API First 開發策略裡有提供一些想法可以參考一下,或是看一下Ruddy老師寫的API first。
剛剛說可以獨立佈署,不代表就一定要佈署到不同站台,時常規模小的專案可以放同一個站台就好。同一個站台(相同port)也可以設定比較嚴格的CORS政策。好了講這麼多,在warp裡要提供靜態網站的做法也很簡單,只要設定路由和對應的資料夾就好了,其實靜態網站只是把服務器上的檔案左手轉右手拿給用戶端而已。
在warp中我們只要加上一條接資料夾路徑的路由就好:
// web/src/routers.rs
pub fn all_routers(ctx: AppContext)
// ... 略
let static_files = warp::path("static") // 設定路由
.and(warp::fs::dir("./static")); // 設定檔案資料夾
// ... 略
hello
.or(static_files) // 註冊 handler
// ... 略
雖然都叫static
,在path
裡的static
是指開啟網頁的網址路由,如http://localhost:3030/static/hello.md
,而dir
裡的static
是指我們專案裡的static
資料夾,如果是production環境就是執行檔所在的位置裡的static
資料夾。
static/
├── hello.md
└── test.html
我們放上兩個檔案實測一下:
static/hello.md:
# Hello
This is a test.
## Subsection
This is a subsection.
## Another subsection
This is another subsection.
```
// here is the code for the app.rs file:
```
static/test.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1> This is static web page </h1>
Lorem ipsum dolor sit amet, consectetur adipisicing elit.
Accusantium ex nihil officia quod! Asperiores blanditiis
debitis eligendi iste maxime possimus provident quibusdam
quod, ut velit! Cum odit perspiciatis provident quo.
</body>
</html>
實測一下:
這兩個A在各應用程式裡算是很基礎的應用,都是Auth開頭,大家容易搞混:
前後端分離後,較廣為流程的rest api是使用http請求/回應來實現,因為每次請求都是獨立的,後端無法得知這個請求是誰打過來的,所以一般會再發一個令牌給前端,前端往後的每次請求就要夾帶這個令牌,讓後端檢驗要不要放行。
令牌一般由資料和簽章組成,資料可以寫明這是誰、令牌發行單位、令牌核發時間、令牌效期、而這個令牌因為是數據資料很容易複製,所以還要加一個簽章來驗明真偽,而簽章的方式只有後端在發行令牌的演算法才知道,如此可以降低被別人偽造的風險。
但如果令牌內容連簽章都一起被偷,那別人拿到就可以做任何你可以做的事,所以有一些攻擊就是試圖取得使用者的令牌,有些人會用比較短的效期,並定期renew新的令牌,這就依不同的場景做取捨,以在企業內部網域來說,就不見得需要用這麼嚴格的規範。
一般而言,後端在核發令牌的時候,會紀錄一些必要資訊,讓大部分的後端操作,可以簡易判斷認證與授權,不必每次都去DB查找,畢竟資料庫存取的成本頗高,如果流量大的話,可能容易造成延遲或是其他問題。
前端令牌可以存放在cookie、local storage、session storage、IndexedDB,儲存型式不拘,但要依後端所要求的型式傳送,後端才能正確解析。
JWT(JSON Web Token)是一個標準(RFC 7519)。還是那句老話,有業界標準就不要自幹,除非能像某A社一樣自創標準讓大家follow。JWT是一個相對比較容易實現的認證機制,一般也常使用在HTTP REST API上,用法就是在http header加上Authorization: Bearer <token>
。這裡注意一下這個 Bearer
並不是JWT專屬的,Bearer
是OAuth製定的另一個規範 RFC 6750,並沒有指定要使用什麼方式實作token,JWT只是其中一種產生/驗證token的方法。
其他不同的http 認證機制可以參見此文章。
如前一節所述,令牌(token)上有資料和簽章,而JWT再把資料拆成2個部分,Header表頭與Payload內文,因為都是明碼,所以不能放敏感資訊,jwt.io網站就有提供debugger,我們一起看一下:
詳細的JWT介紹可以參考五倍學院寫的:什麼是 JWT ?
先耐下心來看一下黃黃那一條警告(可能大部分的人在看文件都直接跳過,尤其是英文的):警語說JWT是Credential(證件)訊息,叫我們不要亂貼,就像我們身份證不會隨便給別人一樣。接著又說在這網站的驗證功能是寫在用戶端,不會傳到server去。現在線上工具很發達,所以有很多線上編碼/解碼的工具雖然用起來很方便,但在使用前最好停下來想一下自己要貼的東西有沒有涉及一些機敏訊息。
編解碼是 encode/decode,加解密是 encrypt/decrypt,這兩個是不相同的東西,注意不要誤把encode當加密就搞笑了,時常都會聽到很多人誤把encode以為encrypt,身為軟體開發人員如果不知道有點糟糕,尤其在現今資訊很不安全的環境之下。
左半邊 Encoded 是編碼後的token,這是使用Base64編碼:eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.GuoUe6tw79bJlbU1HU0ADX0pr0u2kf3r_4OdrDufSfQ
右半邊把這個token解碼為Header/Payload,並提供驗章功能:
alg
是指演算法,指的是簽章的演算法。sub
是subject,可看作是識別id。右邊滑鼠移上去就會顯示縮寫的內容,本截圖滑鼠停在iat(發行時間)的值上,所以debugger幫我們解析為時間供我們參考。
還是簡單講一下好了,編碼就是用另一種表達,比如「你」可以使用注音文「ㄋーˇ」可以使用「You」,都很容易判讀,base64對電腦來說也很容易判讀。加密就像上了一把鎖,被別人拿到鎖了也沒用,因為沒有鑰匙就打不開,除非暴力破解,越簡單的演算法(便宜的鎖)越容易被暴力破解。
實作,參考rust jwt套件的範例,先加上相關套件的安裝,其中處理時間的chrono套件裝過,只要web專案補上就好:
@@ Cargo.toml @@
[workspace.dependencies]
+argon2 = { version = "0.5" }
+hmac = { version = "0.12" }
+jwt = { version = "0.16" }
+sha2 = { version = "0.10" }
@@ web/Cargo.toml @@
[dependencies]
+argon2 = { workspace = true }
+chrono = { workspace = true }
+hmac = { workspace = true }
+jwt = { workspace = true }
+sha2 = { workspace = true }
@@ web/src/lib.rs @@
+pub mod auth;
另外開一個auth.rs
檔案專門來處理我們的雙A,首先套件的payload是使用BTreeMap,如果在這邊想要簡單用Dictionary的話不能直接用rust的HashMap。JWT的簽章是把Header和Payload加在一起做,而Payload如果用HashMap的話不保證順序一致,而BTree因為效能的調校已把Key做優化排序,所以可以保證Payload序列化為JSON的值不會跳來跳去的。而我們也可以自己刻一個Claim物件,方便我們調用:
// web/src/auth.rs
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
pub sub: String, // 用戶唯一識別id
pub exp: i64, // 到期日期時間 (Unix Timestamp)
pub permissions: Vec<u16>, // 權限
}
JWT的到期日時間戳使用Unix Timestamp,從1970-01-01T00:00:00Z
到現在的秒數,值得注意的是,因為JWT不能註銷,發了就是發了,發行後蓋了章,未來不管再怎麼驗,驗幾次,過期了再驗還是會驗證成功,所以我們需要透過一個exp
過期的欄位,來做為要不要放行的依據,到期了也不能再重新展期,只能重新發一個新的。
而權限的部分先加上去作為後續示範使用,雖然JWT沒有限制大小,可是問題是JWT token放在Http Header依不同Web Server有不同大小的限制,不過可以的話還是讓它儘可能的小,畢竟每次傳輸都要夾帶,所以這裡的權限我使用整數。當然做法有很多,也可以jwt保留用戶帳號就好,權限cache在服務器端,不過待會兒要來演示從jwt取資料,所以這裡先把permission放jwt裡。
接著加上簽發token的function:
// web/src/auth.rs
use std::env;
use hmac::{Hmac, digest::{InvalidLength, KeyInit}};
use jwt::{AlgorithmType, Header, SignWithKey, Token};
use sha2::Sha384;
use crate::error::AppError;
/// 使用設定的Claims物件及Key值產生JWT
pub fn generate_jwt(key: Hmac<Sha384>, claims: Claims)
-> Result<String, AppError> {
let header = Header {
algorithm: AlgorithmType::Hs384,
..Default::default()
};
let token = Token::new(header, claims)
.sign_with_key(&key)?;
Ok(token.as_str().to_string())
}
/// 把字串轉換為加密用的Key物件
pub fn key_from_secret(secret: String)
-> Result<Hmac<Sha384>, AppError> {
let key = Hmac::new_from_slice(secret.as_bytes())?;
Ok(key)
}
/// 從環境變數取得JWT_SECRET作為簽章使用的KEY值
pub fn key() -> Hmac<Sha384> {
let secret = env::var("JWT_SECRET")
.unwrap_or("some-secret".to_string());
let key = Hmac::new_from_slice(secret.as_bytes()).unwrap();
key
}
寫好後處理一下套件Error的轉換,上面程式裡的?
才可以正確work,並加測試跑看看上面的程式有沒有寫好:
// web/src/auth.rs
impl From<InvalidLength> for AppError {
fn from(value: InvalidLength) -> Self {
AppError::BadRequest(value.to_string())
}
}
impl From<jwt::Error> for AppError {
fn from(_value: jwt::Error) -> Self {
AppError::Unauthorized
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_jwt() {
let key = key_from_secret("hello".to_string()).unwrap();
let claims = Claims {
sub: "someone".to_string(),
exp: 1000,
permissions: vec![],
};
let jwt = generate_jwt(key, claims).unwrap();
assert_eq!(jwt, "eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJzb21lb25lIiwiZXhwIjoxMDAwLCJwZXJtaXNzaW9ucyI6W119.Nf-IAcni3bO3W1c8lHeRr3B9zxuD9aJoheZIzacxWc8JpRId9WOMjAyy4va7ltpt");
}
}
測試順利地通過了,可以正確的產製token。
我們再繼續往下加驗證token的function:
// web/src/auth.rs
use jwt::{AlgorithmType, Header, SignWithKey, Token, VerifyWithKey};
use chrono::{TimeZone, Utc};
pub fn verify_jwt(key: Hmac<Sha384>, token: String)
-> Result<Claims, AppError> {
let verify: Result<Token<Header, Claims, _>, _>
= token.verify_with_key(&key);
match verify {
Ok(token) => {
let claims: Claims = token.claims().clone();
let expiry = Utc.timestamp_opt(claims.exp, 0).unwrap();
let now = Utc::now();
if now > expiry {
return Err(AppError::Unauthorized);
}
Ok(claims)
}
Err(_) => Err(AppError::Unauthorized),
}
}
這邊進行驗章後,解出claims並沒有直接回傳,而是先判斷token的效期,如果過期的話會拋出錯誤,而正確的話才會回傳token的payload。實測一下:
mod tests {
// 使用 exp = 1000 => 1970-01-01 00:00:01,為過期的時間
#[test]
fn test_verify_expired_jwt_token() {
let key = key_from_secret("hello".to_string()).unwrap();
let token = "eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJzb21lb25lIiwiZXhwIjoxMDAwfQ.fZNwRLGWCv9VsZYzU0jT3b-87SVFqFGlivzNeQCEOOLA1ARjAnoMPloyjs4_BEWt";
let claim = verify_jwt(key, token.to_string());
let error = claim.unwrap_err();
assert_eq!(error, AppError::Unauthorized);
}
// 使用 exp = 3000000000 => 2065-01-24 05:20:00
#[test]
fn test_verify_valid_jwt_token() {
let key = key_from_secret("hello".to_string()).unwrap();
let token = "eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJzb21lb25lIiwiZXhwIjozMDAwMDAwMDAwLCJwZXJtaXNzaW9ucyI6W119.eu9ZQsu7GSUWEXugMD9AuRz_nuW23fk-16f4qGq4yJUSQFFWiAvQtLzK9lZN03Ef";
let claim = verify_jwt(key, token.to_string()).unwrap();
assert_eq!(claim.sub, "someone");
assert_eq!(claim.exp, 3000000000);
}
}
測試都通過,我們現在已經可以使用JWT來正確簽章/驗章。接下來處理auth的middleware比較複雜一點點,我們下一篇再來處理。
本系列專案源始碼放置於 https://github.com/kenstt/demo-app
exp能不能用u64,不可能發個五十年前過期的token吧...對吧...
大大厲害唷一下子看到小細節。
一開始的確用u64,不過從chrono套件取timestamp是i64,當時不想再轉型就跟著用一樣的 XDD:
//https://docs.rs/chrono/latest/chrono/struct.DateTime.html#method.timestamp
pub fn timestamp(&self) -> i64 {
self.datetime.timestamp()
}