精品欧美一区二区三区在线观看 _久久久久国色av免费观看性色_国产精品久久在线观看_亚洲第一综合网站_91精品又粗又猛又爽_小泽玛利亚一区二区免费_91亚洲精品国偷拍自产在线观看 _久久精品视频在线播放_美女精品久久久_欧美日韩国产成人在线

通過實例理解Go Web身份認證的幾種方式

開發 前端
本文我們介紹了多種Web應用的身份認證技術方案,各種認證技術會依據對安全性、使用性和擴展性的不同需求而存在和發展。了解每種技術的原理和優劣勢,可幫助我們更好地選擇適合的方案。

在2023年Q1 Go官方用戶調查報告[1]中,API/RPC services、Websites/web services都位于使用Go開發的應用類別的頭部(如下圖):

圖片

我個人使用Go開發已很多年,但一直從事底層基礎設施、分布式中間件等方向,Web應用開發領域涉及較少,像Web應用領域常見的CRUD更是少有涉獵,不能不說是一種“遺憾”^_^。未來一段時間,團隊會接觸到Web應用的開發,我打算對Go Web應用開發的重點環節做一個快速系統的梳理。

而身份認證(Authentication,簡稱AuthN)是Web應用開發中一個關鍵的環節,也是首個環節,它負責驗證用戶身份,讓用戶可以以認證過的身份訪問系統中的資源和信息。

Go語言作為一門優秀的Web開發語言,提供了豐富的機制來實現Web應用的用戶身份認證。在這篇文章中,我就通過Go示例和大家一起探討一下當前Web應用開發中幾種常見的主流身份認證方式,幫助自己和各位讀者邁出Web應用開發修煉之路的第一步。

1.身份認證簡介

1.1 身份認證解決的問題

身份認證不局限于Web應用,各種系統都會有身份認證,但本文我們聚焦Web應用領域的身份認證技術。

幾乎所有Web應用的安全性都是從身份認證開始的,身份認證是驗證用戶身份真實性的過程,是我們首先要部署的策略。位于下游的安全控制,如授權(Authorization, AuthZ)、審計日志(Audit log)等,幾乎都需要用戶的身份。

身份認證的英文是Authentication,簡寫為AuthN,大家不要將之與授權Authorization(AuthZ)混淆(在后續系列文章中會繼續探討AuthZ相關的內容),他們所要解決的問題相似,但有不同,也有先后。通常先AuthN,再AuthZ。我們可以用下面的比喻來形象地解釋二者的聯系與差異:

AuthN就像是進入公司大樓的安檢,負責檢查員工的身份是否合法,是否具有進入公司的資格,它解決的是驗證員工身份的問題。

AuthZ更像是公司內部的權限管理,某個員工進入了公司后(AuthN后)想訪問一些重要資料,這時還需要確認該員工是否有相應的訪問權限。它解決的是授權訪問控制的問題。

簡單來說,AuthN是驗證你是誰,authZ是驗證你有哪些權限。AuthN解決認證問題,AuthZ解決授權問題,這兩個都重要,AuthN解決外部的安全問題,authZ解決內部的安全與合規問題。

1.2 身份認證的三要素

身份認證需要被認證方提供一些身份信息輸入,這些代表身份信息的輸入被稱為身份認證要素(authentication factor)。這些要素有很多,大致可分為三類:

  • 你知道的東西(What you know)

即基于被認證方知道的特定信息來驗證身份,最常見的如密碼等。

  • 你擁有的東西(What you have)

基于被認證方所擁有的特定物件來驗證身份,最常見的利用數字證書、令牌卡等。N年前,在移動端應用還沒有發展起來時,一些人在銀行辦理電子銀行業務時會拿到一個U盾(又稱為USBKey),其中存放著用于用戶身份識別的數字證書,這個U盾就屬于此類要素。

上面比喻中進入大樓時使用的員工卡也屬于這類要素。

  • 你本身就具有的(What you are)

即基于被認證方所擁有的生物特征要素(biometric factor)來驗證身份,最常見的人臉識別、指紋/聲紋/虹膜識別和解鎖等。理論上來說,具備個人生物特征的身份認證標志具有不可仿冒性、唯一性。

如果上面比喻中的大樓已經開啟了人臉識別功能,那么基于人臉識別的認證就屬于這類要素的認證。

通常我們會基于單個要素設計身份認證方案,一旦使用兩個或兩個以上不同類的要素,就可以被稱為**雙因素認證(2FA)[2]或多因素認證(MFA)**了。不過,2FA和MFA都比較復雜,不再本篇文章討論范圍之內。

基于上述要素,我們就可以設計和實現各種適合不同類別Web應用或API服務的身份認證方法了。Web應用和API服務都需要身份認證,它們有什么差異呢?這些差異是否會對身份認證方案產生影響呢?我們接下來看一下。

1.3 Web應用身份認證 vs. API服務身份認證

Web應用和API服務主要有以下幾點區別:

  • 交互方式不同

Web應用是瀏覽器與服務器之間的交互,用戶通過瀏覽器訪問Web應用。而API服務是程序/應用與服務器之間的交互,通過API請求獲取數據或執行操作。

  • 返回數據格式不同

Web應用通常會返回html/js/css等瀏覽器可解析執行的代碼,而API服務通常返回結構化數據,常見的如JSON或XML等。

  • 使用場景不同

Web應用主要面向人類用戶的使用,用戶通過瀏覽器進行操作。而API服務主要被其他程序調用,為程序之間提供接口與數據支撐。

  • 狀態管理不同

Web應用在服務端保存會話狀態,瀏覽器通過cookie等保存用戶狀態。而API服務通常是無狀態的,每次請求都需要攜帶用于身份認證的信息,比如訪問令牌或API Key等。

  • 安全方面的關注點不同

Web應用更關注XSS[3]、CSRF[4]等輸入驗證安全,而API服務更關注身份認證(authN)、授權(authZ)、準入(admission)、限流等訪問控制安全。

總之,Web應用注重界面的展示和用戶交互;而API服務注重數據和服務的提供,它們有不同的使用場景、交互方式和安全關注點。

Web應用和API服務的這些差異也導致了Web應用和API服務適合使用的身份認證方案上會有所不同。但前后端分離架構的出現和普及,讓前后端責任分離:前端專注于視圖和交互,后端專注數據和業務,并且前后端通過標準化的API接口進行數據交互。這可以讓后端提供統一的認證接口,不同的前端可以共享。像基于Token這樣的無狀態易理解的身份驗證機制逐漸成為主流。也就是說,架構模式的變化,使得Web應用和API服務在身份驗證(authN)方案上出現了一些融合的現象,因此在身份認證方法上,Web應用和API服務也存在一些交集。

下面維韋恩圖列出了三類身份認證方法,包括僅適用于Web應用的、僅適用于API服務的以及兩者都適用的:

圖片圖片

本文聚焦Web應用的身份認證方式,接下來會重點說說上圖中綠色背景色的幾種身份認證方式。

2. 安全信道是身份認證的前提和基礎

在對具體的Web身份認證方式進行說明之前,我們先來了解一下身份認證的前提和基礎 - 安全信道。

在Web應用身份認證的過程中,無論采用何種認證方式,用戶的身份要素信息(用戶名/密碼、token、生物特征信息)都要傳遞給服務器,這時候如果傳遞此類信息的通信信道不安全,這些重要的認證要素信息就很容易被中間人截取、破解、篡改并被冒充,從而獲得Web應用的使用權。從服務端角度來看,如果沒有安全信道,服務器身份也容易被偽裝,導致用戶連接到“冒牌服務器”并導致嚴重后果。因此,沒有建立在安全信道上的身份認證是不安全,不具備實際應用價值的,甚至是完全沒有意義的。

此外,安全信道不僅對登錄階段的身份認證環節有重要意義,在用戶已登錄并訪問Web應用其他功能頁面時,安全通道也可以對數據的傳輸以及類似訪問令牌或Cookie數據的傳輸起到加密和保護作用。

在Web應用領域,最常用的安全信道建立方式是基于HTTPS(HTTP over TLS)或直接建立在TLS之上的自定義通信,TLS利用證書對通信進行加密、驗證服務器身份(甚至是客戶端身份的驗證),保障信息的機密性和完整性。各大安全規范和標準如PCI DSS(Payment Card Industry Data Security Standard)[5]、OWASP[6]也強制要求使用HTTPS保障認證安全。

基于安全信道,我們還可以實施第一波的身份認證,這就是我們通常所說的基于HTTPS(或TLS)的雙向身份認證。

注:在我的《Go語言精進之路vol2》[7]一書中,對TLS的機制以及基于Go標準庫的TLS的雙向認證[8]有系統全面的說明,歡迎各位童鞋閱讀反饋。

這種認證方式采用的是身份認證要素中的第二類要素:What you have。客戶端帶著歸屬于自己的專有證書去服務端做身份驗證。如果client證書通過服務端的驗簽后,便可允許client進入“大樓”。

下面是一個基于TLS證書做身份認證的客戶端與服務端交互的示意圖:

圖片圖片

我們先看看對應上述示意圖中的客戶端的代碼:

// authn-examples/tls-authn/client/main.go

func main() {

 // 1. 讀取客戶端證書文件
 clientCert, err := tls.LoadX509KeyPair("client-cert.pem", "client-key.pem")
 if err != nil {
  log.Fatal(err)
 }

 // 2. 讀取中間CA證書文件
 caCert, err := os.ReadFile("inter-cert.pem")
 if err != nil {
  log.Fatal(err)
 }
 certPool := x509.NewCertPool()
 certPool.AppendCertsFromPEM(caCert)

 // 3. 發送請求

 client := &http.Client{
  Transport: &http.Transport{
   TLSClientConfig: &tls.Config{
    Certificates: []tls.Certificate{clientCert},
    RootCAs:      certPool,
   },
  },
 }

 req, err := http.NewRequest("GET", "https://server.com:8443", nil)
 if err != nil {
  log.Fatal(err)
 }
 resp, err := client.Do(req)
 if err != nil {
  log.Fatal(err)
 }

 // 4. 打印響應信息
 fmt.Println("Response Status:", resp.Status)
 // fmt.Println("Response Headers:", resp.Header)
 body, _ := io.ReadAll(resp.Body)
 fmt.Println("Response Body:", string(body))
}

客戶端加載client-cert.pem作為后續與服務端通信的身份憑證,加載inter-cert.pem用于校驗服務端在tls握手過程發來的服務端證書(server-cert.pem),避免連接到“冒牌站點”。通過驗證后,客戶端向服務端發起Get請求并輸出響應的內容。

下面是服務端的代碼:

// authn-examples/tls-authn/server/main.go

func main() {
 var validClients = map[string]struct{}{
  "client.com": struct{}{},
 }

 // 1. 加載證書文件
 cert, err := tls.LoadX509KeyPair("server-cert.pem", "server-key.pem")
 if err != nil {
  log.Fatal(err)
 }

 caCert, err := os.ReadFile("inter-cert.pem")
 if err != nil {
  log.Fatal(err)
 }
 certPool := x509.NewCertPool()
 certPool.AppendCertsFromPEM(caCert)

 // 2. 配置TLS
 tlsConfig := &tls.Config{
  Certificates: []tls.Certificate{cert},
  ClientAuth:   tls.RequireAndVerifyClientCert, // will trigger the invoke of VerifyPeerCertificate
  ClientCAs:    certPool,
 }

 // tls.Config設置
 tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
  // 獲取客戶端證書
  cert := verifiedChains[0][0]

  // 提取CN作為客戶端標識
  clientID := cert.Subject.CommonName
  fmt.Println(clientID)

  _, ok := validClients[clientID]
  if !ok {
   return errors.New("invalid client id")
  }

  return nil
 }
 // 添加處理器
 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
  w.Write([]byte("Hello World!"))
 })

 // 3. 創建服務器
 srv := &http.Server{
  Addr:      ":8443",
  TLSConfig: tlsConfig,
 }

 // 4. 啟動服務器
 err = srv.ListenAndServeTLS("", "")
 if err != nil {
  log.Fatal(err)
 }
}

注:在你的實驗環境中,需要在/etc/hosts文件中添加server.com的映射ip為127.0.0.1。

服務端代碼也不復雜,比較“套路化”:加載服務端證書和中間CA證書(用于驗簽client端的證書),這里將tls.Config.ClientAuth設置為RequireAndVerifyClientCert,這會觸發服務端對客戶端證書的驗簽,同時在tlsConfig.VerifyPeerCertificate不為nil的情況下,觸發對tlsConfig.VerifyPeerCertificate的函數的調用,在示例代碼中,我們為tlsConfig.VerifyPeerCertificate賦值了一個匿名函數實現,在這個函數中,我們提取了客戶端證書中的客戶端標識CN,并查看其是否在可信任的客戶端ID表中。

在這個示例中,這個tlsConfig.VerifyPeerCertificate執行的驗證有些多余,但我們在實際代碼中可以使用tlsConfig.VerifyPeerCertificate來設置黑名單,攔截那些尚未過期、但可以驗簽通過的客戶端,實現一種客戶端證書過期前的作廢機制。

此外,上述示例中客戶端、服務端以及中間CA證書的制作代碼與《Go TLS服務端綁定證書的幾種方式》[9]一文中的證書制作很類似,大家可以直接參考本文示例代碼中的tls-authn/make-certs下面的代碼,這里就不贅述了。

通過這種基于安全信道的身份驗證方式,客戶端證書可以強制認證用戶,理論上不需要額外再用用戶名密碼。認證之后客戶端在這個TLS連接上發送的所有信息都將綁定其身份。

不過通過頒發客戶端專用證書的方式僅適合一些像網絡銀行之類的專有業務,大多數Web應用會與客戶端間建立安全信道,但不會采用客戶端證書來認證用戶身份,在這樣的情況下,下面要說的這些身份認證方式就可以發揮作用了。

我們先來看一下最傳統的基于密碼的認證。

3. 基于密碼的認證

基于密碼的認證屬于基于第一類身份認證要素:你知道的東西(What you know)的認證方式,這類認證也是Web應用中最經典、最常見的認證方式。我們先從基于傳統表單承載用戶名/密碼說起。

3.1. 基于用戶名+密碼的認證(傳統表單方式)

這是最常見的Web應用認證方式:用戶通過提交包含用戶名和密碼的表單(Form),服務端Web應用進行驗證。下面使用這種方式的客戶端與服務單的交互示意圖:

圖片圖片

接下來,我們看看對應上述示意圖的實現代碼。我們先建立一個html文件,該文件非常簡單,就是一個可輸入用戶名和密碼的表單,點擊登錄按鈕將表單信息發送到服務端:

// authn-examples/password/classic/login.html

<!DOCTYPE html>
<html>
<head>
  <title>登錄</title>
</head>
<body>

<form actinotallow="http://server.com:8080/login" method="post">

  <label>用戶名:</label>
  <input type="text" name="username"/>

  <label>密碼:</label>
  <input type="password" name="password"/>

  <button type="submit">登錄</button>

</form>

</body>
</html>

發送的HTTP Post請求的包體(Body)中會包含頁面輸入的username和password的值,形式如下:

username=admin&password=123456

而我們的服務端的代碼如下:

// authn-examples/password/classic/main.go

func main() {
    http.HandleFunc("/login", login)
    http.ListenAndServe(":8080", nil)
}

func login(w http.ResponseWriter, r *http.Request) {
    username := r.FormValue("username")
    password := r.FormValue("password")

    if isValidUser(username, password) {
        w.Write([]byte("Welcome!"))
        return
    }

    http.Error(w, "Invalid username or password", http.StatusUnauthorized) // 401
}

var credentials = map[string]string{
    "admin": "123456",
}

func isValidUser(username, password string) bool {
    // 驗證用戶名密碼
    v, ok := credentials[username]
    if !ok {
        return false
    }

    if v != password {
        return false
    }
    return true
}

服務端通過Request的FormValue方法獲得username和password的值,并與credentials存儲的合法用戶信息比對(當然這只是演示代碼中的臨時手段,生產中不要這么存儲用戶信息),比對成功,返回"Welcome"應答;比對失敗,返回401 Unauthorized錯誤。

注:包括本示例在內的后續所有示例的客戶端和服務端都在非安全信道上通信,目的是簡化示例代碼的編寫。大家在生產環境務必建立安全信道后再做后續的身份驗證。

基于傳統的表單用戶名和密碼可以作為Web應用服務端身份驗證的方案,但問題來了:服務端認證成功后,用戶后續向Web應用服務端發起的請求是否還要繼續帶上用戶和密碼信息呢?如果不帶上用戶和密碼信息,服務端又如何驗證這些請求是來自之前已經認證成功后的用戶;如果后續每個請求都帶上以Form形式承載的用戶名和密碼,使用起來又非常不方便,還影響后續請求的正常數據的傳輸(對Body數據有侵入)。

于是便有了Session(會話)機制,它可以被認為是基于經典的用戶名密碼(表單承載)認證方式的“延續”,使得密碼認證的成果不再局限在缺乏連續性的單一請求級別上,而是擴展到后續的一段時間內或一系列與Web應用的互操作過程中,變成了連續、持久的登錄會話。

接下來,我們就來簡單看看基于Session的后續認證方式是如何工作的。

3.2 使用Session:有狀態的認證方式

基于Session的認證方式是一種有狀態的方案,服務端會為每個身份認證成功的用戶建立并保存相關session信息,同時服務端也會要求客戶端在瀏覽器側持久化與該Session有關少量信息,通常客戶端會通過開啟Cookie的方式來保存與用戶Session相關的信息。

服務端保存Session有多種方式,可以在進程內存中、文件中、數據庫、緩存(Redis)等,不同方式各有優缺點,比如將Session保存在內存中,最大的好處就是實現簡單且速度快,但由于不能持久化,服務實例重啟后就會丟失,此外當服務端有多副本時,session信息無法在多實例共享;使用關系數據庫來保存session,可以方便持久化,也方便與服務端多實例用戶數據共享,但數據庫交互成本較大;而使用緩存(Redis)存儲session信息是目前比較主流的方式,簡單、安全、快速,還可以很好地適合分布式環境下session的共享。

下面是一個常見的基于cookie實現的session機制的客戶端與服務端的交互示意圖:

圖片圖片

這里也給出上述示意圖的一個參考實現示例(代碼僅用作演示,很多值設置并不規范和安全,不要用于生產)。

session機制的開啟從用戶登錄開始,這個示例里的login.html與上一個示例是一樣的:

// authn-examples/password/session/login.html

<!DOCTYPE html>
<html>
<head>
  <title>登錄</title>
</head>
<body>

<form actinotallow="http://server.com:8080/login" method="post">

  <label>用戶名:</label>
  <input type="text" name="username"/>

  <label>密碼:</label>
  <input type="password" name="password"/>

  <button type="submit">登錄</button>
  
</form>

</body>
</html>

服務端負責的login Handler代碼如下:

// authn-examples/password/session/main.go

var store = sessions.NewCookieStore([]byte("session-key"))

func main() {
 http.HandleFunc("/login", login)
 http.HandleFunc("/calc", calc)
 http.HandleFunc("/calcAdd", calcAdd)

 http.ListenAndServe(":8080", nil)
}

var credentials = map[string]string{
 "admin": "123456",
 "test":  "654321",
}

func isValid(username, password string) bool {
 // 驗證用戶名密碼
 v, ok := credentials[username]
 if !ok {
  return false
 }

 if v != password {
  return false
 }
 return true
}

func base64Encode(src string) string {
 encoded := base64.StdEncoding.EncodeToString([]byte(src))
 return encoded
}

func base64Decode(encoded string) string {
 decoded, _ := base64.StdEncoding.DecodeString(encoded)
 return string(decoded)
}

func randomStr() string {
 // 生成隨機數
 rand.Seed(time.Now().UnixNano())
 random := rand.Intn(100000)

 // 格式化為05位字符串
 str := fmt.Sprintf("%05d", random)

 return str
}

func login(w http.ResponseWriter, r *http.Request) {
 username := r.FormValue("username")
 password := r.FormValue("password")

 if isValid(username, password) {
  session, err := store.Get(r, "server.com_"+username)
  if err != nil {
   fmt.Println("get session from session store error:", err)
   http.Error(w, "Internal error", http.StatusInternalServerError)
  }

  // 設置session數據
  random := randomStr()
  usernameB64 := base64Encode(username + "-" + random)
  session.Values["random"] = random
  session.Save(r, w)

  // 設置cookie
  cookie := http.Cookie{Name: "server.com-session", Value: usernameB64}
  http.SetCookie(w, &cookie)

  // 登錄成功,跳轉到calc頁面
  http.Redirect(w, r, "/calc", http.StatusSeeOther)
 } else {
  http.Error(w, "Invalid username or password", http.StatusUnauthorized) // 401
 }
}

我們使用了gorilla/sessions這個Go社區廣泛使用的session庫來實現服務端session的相關操作。以admin用戶登錄為例,當用戶名和密碼認證成功后,我們在session store中創建一個新的session:server.com_admin。然后生成一個隨機數,將隨機數存儲在該session的名為"random"的key的下面。之后,讓客戶端設置cookie,name為server.com-session。值為username和random按特定格式組合后的base64編碼值。

登錄成功后,瀏覽器會跳到calc頁面,這里我們輸入兩個整數,并點擊"calc"按鈕提交,提交動作會發送請求到calcAdd Handler中:

// authn-examples/password/session/main.go

func calcAdd(w http.ResponseWriter, r *http.Request) {
 // 1. 獲取Cookie中的Session
 cookie, err := r.Cookie("server.com-session")
 if err != nil {
  http.Error(w, "找不到cookie,請重新登錄", 401)
  return
 }
 fmt.Printf("found cookie: %#v\n", cookie)

 // 2. 獲取Session對象
 usernameB64 := cookie.Value
 usernameWithRandom := base64Decode(usernameB64)

 ss := strings.Split(usernameWithRandom, "-")
 username := ss[0]
 random := ss[1]
 session, err := store.Get(r, "server.com_"+username)
 if err != nil {
  http.Error(w, "找不到session, 請重新登錄", 401)
  return
 }

 randomInSs := session.Values["random"]
 if random != randomInSs {
  http.Error(w, "session中信息不匹配, 請重新登錄", 401)
  return
 }

 // 3. 轉換為整型參數
 a, err := strconv.Atoi(r.FormValue("a"))
 if err != nil {
  http.Error(w, "參數錯誤", 400)
  return
 }

 b, err := strconv.Atoi(r.FormValue("b"))
 if err != nil {
  http.Error(w, "參數錯誤", 400)
  return
 }

 // 4. 計算并返回結果
 result := a + b
 w.Write([]byte(fmt.Sprintf("%d", result)))
}

calcAdd Handler會提取Cookie "server.com-session"中的值,根據值信息查找服務端本地是否存儲了對應的session,并校驗與session中存儲的隨機碼是否一致。驗證通過后,直接返回結算結果;否則提醒客戶端重新登錄。

前面說過,session是一種有狀態的輔助身份認證機制,需要客戶端和服務端的配合完成,一旦客戶端禁用了Cookie機制,上述的示例實現就失效了。當然有讀者會說,Session可以不基于Cookie來實現,可以用URL重寫、隱藏表單字段、將Session ID放入URL路徑等方式來實現,客戶端也可以用LocalStorage等前端存儲機制來替代Cookie。但無論哪種實現,這種有狀態機制帶來的復雜性都不低,并且在分布式環境中需要session共享和同步機制,影響了scaling。

隨著微服務架構的廣泛使用,無需在服務端存儲額外信息、天然支持后端服務分布式多實例的無狀態的連續身份認證機制受到了更多的青睞。

其實基于HTTP的無狀態認證機制早已有之,最常見的莫過于Basic Auth了,接下來,我們就從Basic Auth開始,說幾種無狀態身份認證機制。

3.3 Basic Auth:最早的無狀態認證方式

Basic Auth是HTTP最原始的身份驗證方式,在HTTP1.0規范中就已存在,其原因是HTTP是無狀態協議,每次請求都需要進行身份驗證才能訪問受保護資源。

Basic Auth的原理也十分簡單,客戶端與服務端的交互如下圖:

圖片圖片

Basic Auth通過在客戶端的請求報文中添加HTTP Authorization Header的形式向服務器端發送認證憑據。HTTP Authorization Header的構建通常分兩步。

  • 將“username:password”的組合字符串進行Base64編碼,編碼值記作b64Token。
  • 將Authorization: Basic b64Token作為HTTP header的一個字段發送給服務器端。

服務端收到請請求后提取出Authorization字段并做Base64解碼,得到username和password,然后與存儲的信息作比對進行客戶端身份認證。

我們來看一個與上圖對應的示例的代碼,先看客戶端:

// authn-examples/password/basic/client/main.go

func main() {
 client := &http.Client{}
 req, _ := http.NewRequest("POST", "http://server.com:8080/", nil)

 // 發送默認請求
 response, err := client.Do(req)
 if err != nil {
  fmt.Println(err)
  return
 }

 // 解析響應頭
 authHeader := response.Header.Get("WWW-Authenticate")
 loginReq, _ := http.NewRequest("POST", "http://server.com:8080/login", nil)
 username := "admin"
 password := "123456"

 // 判斷認證類型
 if !strings.Contains(authHeader, "Basic") {
  // 不支持的認證類型
  fmt.Println("Unsupported authentication type:", authHeader)
  return
 }

 // 使用Basic Auth, 添加Basic Auth頭
 loginReq.SetBasicAuth(username, password)
 response, err = client.Do(loginReq)

 // 打印響應狀態
 fmt.Println(response.StatusCode)

 // 打印響應包體
 defer response.Body.Close()
 body, err := io.ReadAll(response.Body)
 if err != nil {
  fmt.Println(err)
  return
 }
 fmt.Println(string(body))
}

客戶端的代碼比較簡單,并且流程與圖中的交互流程是完全一樣的。而服務端就是一個簡單的http server,對來自客戶端的帶有basic auth的請求進行身份認證:

// authn-examples/password/basic/server/main.go

func main() {
 // 創建一個基本的HTTP服務器
 mux := http.NewServeMux()

 username := "admin"
 password := "123456"

 // 針對/的handler
 mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
  // 返回401 Unauthorized響應
  w.Header().Set("WWW-Authenticate", "Basic realm=\"server.com\"")
  w.WriteHeader(http.StatusUnauthorized)
 })

 // login handler
 mux.HandleFunc("/login", func(w http.ResponseWriter, req *http.Request) {
  // 從請求頭中獲取Basic Auth認證信息
  user, pass, ok := req.BasicAuth()
  if !ok {
   // 認證失敗
   w.WriteHeader(http.StatusUnauthorized)
   return
  }

  // 驗證用戶名密碼
  if user == username && pass == password {
   // 認證成功
   w.WriteHeader(http.StatusOK)
   w.Write([]byte("Welcome to the protected resource!"))
  } else {
   // 認證失敗
   http.Error(w, "Invalid username or password", http.StatusUnauthorized)
  }
 })

 // 監聽8080端口
 err := http.ListenAndServe(":8080", mux)
 if err != nil {
  log.Fatal(err)
 }
}

采用Basic Auth身份認證方案的客戶端在每個請求中都要在Header中加上Basic Auth形式的身份信息,但服務端無需像Session那樣存儲任何額外的信息。

不過很顯然,Basic Auth這種采用明文傳輸身份信息的方式在安全性方面飽受詬病,為了避免在Header傳輸明文的安全問題,RFC 2617(以及后續更新版RFC 7616)定義了HTTP Digest身份認證方式。Digest訪問認證不再明文傳輸密碼,而是傳遞用hash算法處理后密碼摘要,相對Basic Auth驗證安全性更高。接下來,我們就來看看HTTP Digest認證方式。

3.4 基于HTTP Digest認證

Digest是一種HTTP摘要認證,你可以把它看作是Basic Auth的改良版本,針對Base64明文發送的風險,Digest認證把用戶名和密碼加鹽(一個被稱為Nonce的隨機值作為鹽值)后,再通過MD5/SHA等哈希算法取摘要放到請求的Header中發送出去。Digest的認證過程如下圖:

圖片圖片

相對于Basic Auth,Digest Auth的一些值的生成過程還是略復雜的,這里給出一個示例性質的代碼示例,可能不完全符合Digest規范,大家通過示例理解Digest的認證過程就可以了。

注:如要使用符合RFC 7616的Digest規范(或老版RFC 2617規范),可以找一些第三方包,比如https://github.com/abbot/go-http-auth(只滿足RFC 2617)。

// authn-examples/password/digest/client/main.go

func main() {
 client := &http.Client{}
 req, _ := http.NewRequest("POST", "http://server.com:8080/", nil)

 // 發送默認請求
 response, err := client.Do(req)
 if err != nil {
  fmt.Println(err)
  return
 }

 // 解析響應頭
 authHeader := response.Header.Get("WWW-Authenticate")
 loginReq, _ := http.NewRequest("POST", "http://server.com:8080/login", nil)
 username := "admin"
 password := "123456"

 // 判斷認證類型
 if !strings.Contains(authHeader, "Digest") {
  // 不支持的認證類型
  fmt.Println("Unsupported authentication type:", authHeader)
  return
 }

 // 使用Digest Auth

 //隨機數
 cnonce := GenNonce()

 //生成HA1
 ha1 := GetHA1(username, password, cnonce)

 //構建Authorization頭
 auth := "Digest username=\"" + username + "\", nnotallow=\"" + cnonce + "\", algorithm=MD5, respnotallow=\"" + GetResponse(ha1, cnonce) + "\""

 loginReq.Header.Set("Authorization", auth)
 response, err = client.Do(loginReq)

 // 打印響應狀態
 fmt.Println(response.StatusCode)

 // 打印響應包體
 defer response.Body.Close()
 body, err := io.ReadAll(response.Body)
 if err != nil {
  fmt.Println(err)
  return
 }
 fmt.Println(string(body))
}

// 生成隨機數
func GenNonce() string {
 h := md5.New()
 io.WriteString(h, fmt.Sprint(rand.Int()))
 return hex.EncodeToString(h.Sum(nil))
}

// 根據用戶名密碼和隨機數生成HA1
func GetHA1(username, password, cnonce string) string {
 h := md5.New()
 io.WriteString(h, username+":"+cnonce+":"+password)
 return hex.EncodeToString(h.Sum(nil))
}

// 根據HA1,隨機數生成response
func GetResponse(ha1, cnonce string) string {
 h := md5.New()
 io.WriteString(h, strings.ToUpper("md5")+":"+ha1+":"+cnonce+"::"+strings.ToUpper("md5"))
 return hex.EncodeToString(h.Sum(nil))
}

客戶端使用username、password和隨機數生成摘要以及一個response碼,并通過請求的頭Authorization字段發給服務端。

服務端解析Authorization字段中的各個值,然后采用同樣的算法算出一個新response,與請求中的response比對,如果一致,則認為認證成功:

// authn-examples/password/digest/server/main.go

func main() {
 mux := http.NewServeMux()

 password := "123456"

 // 針對/的handler
 mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
  // 返回401 Unauthorized響應
  w.Header().Set("WWW-Authenticate", "Digest realm=\"server.com\"")
  w.WriteHeader(http.StatusUnauthorized)
 })

 // login handler
 mux.HandleFunc("/login", func(w http.ResponseWriter, req *http.Request) {
  fmt.Println(req.Header)

  //驗證參數
  if Verify(req, password) {
   fmt.Fprintln(w, "Verify Success!")
  } else {
   w.WriteHeader(401)
   fmt.Fprintln(w, "Verify Failed!")
  }
 })

 // 監聽8080端口
 err := http.ListenAndServe(":8080", mux)
 if err != nil {
  log.Fatal(err)
 }
}

func Verify(r *http.Request, password string) bool {
 auth := r.Header.Get("Authorization")
 params := strings.Split(auth, ",")
 var username, cnonce, response string

 for _, p := range params {
  p := strings.Trim(p, " ")
  kv := strings.Split(p, "=")
  if kv[0] == "Digest username" {
   username = strings.Trim(kv[1], "\"")
  }
  if kv[0] == "nonce" {
   cnonce = strings.Trim(kv[1], "\"")
  }
  if kv[0] == "response" {
   response = strings.Trim(kv[1], "\"")
  }
 }

 if username == "" {
  return false
 }

 //根據用戶名密碼及隨機數生成HA1
 ha1 := GetHA1(username, password, cnonce)

 //自己生成response與請求中response對比
 return response == GetResponse(ha1, cnonce)
}

雖然實現了無狀態,安全性也高于Basic Auth,但Digest方式的用戶體驗依然有限:每次向服務端發送請求,客戶端都要進行一次復雜計算,服務端也要再做一次相同的驗算和比對。

那么是否有一種體驗更為良好的無狀態身份認證方式呢?我們接下來看看基于Token的認證方式。

4. 無狀態:基于Token的認證

基于Token的認證方式的備受青睞得益于Web領域前后端分離架構的發展以及微服務架構的流行,在API調用和網站間需要輕量級的認證機制來傳遞用戶信息。Token認證機制正好滿足這一需求,而JWT(JSON Web Token)[10]是目前Token格式標準中使用最廣的一種。

4.1 JWT原理

JWT由頭部(Header)、載荷(Payload)和簽名(Signature)三部分組成,三部分之間用圓點連接,其形式如下:

xxxxx.yyyyy.zzzzz

一個真實的JWT token的例子如下面來自jwt.io[11]站點的截圖):

圖片圖片

JWT token的生成過程也非常清晰,下圖展示了上述截圖中jwt token的生成過程:

圖片圖片

如果你不想依賴第三方庫,也可以自己實現生成token的函數,下面是一個示例:

// authn-examples/jwt/scratch/main.go

package main

import (
 "crypto/hmac"
 "crypto/sha256"
 "encoding/base64"
 "encoding/json"
 "fmt"
)

type Header struct {
 Alg string `json:"alg"`
 Typ string `json:"typ"`
}

type Claims struct {
 Sub  string `json:"sub"`
 Name string `json:"name"`
 Iat  int64  `json:"iat"`
}

// GenerateToken:不依賴第三方庫的JWT生成實現
func GenerateToken(claims *Claims, key string) (string, error) {
 header, _ := json.Marshal(Header{
  Alg: "HS256",
  Typ: "JWT",
 })
 // 序列化Payload
 payload, err := json.Marshal(claims)
 if err != nil {
  return "", err
 }

 // 拼接成JWT字符串
 headerEncoded := base64.RawURLEncoding.EncodeToString(header)
 payloadEncoded := base64.RawURLEncoding.EncodeToString([]byte(payload))

 encodedToSign := headerEncoded + "." + payloadEncoded

 // 使用HMAC+SHA256簽名
 hash := hmac.New(sha256.New, []byte(key))
 hash.Write([]byte(encodedToSign))
 sig := hash.Sum(nil)
 sigEncoded := base64.RawURLEncoding.EncodeToString(sig)

 var token string
 token += headerEncoded
 token += "."
 token += payloadEncoded
 token += "."
 token += sigEncoded

 return token, nil
}

func main() {
 var claims = &Claims{
  Sub:  "1234567890",
  Name: "John Doe",
  Iat:  1516239022,
 }

 result, _ := GenerateToken(claims, "iamtonybai")
 fmt.Println(result)
}

對照著上面圖示的流程,理解這個示例非常容易。當然jwt.io官方也維護了一個使用簡單且靈活性更好的Go module:golang-jwt/jwt[12],用這個go module生成上述token的示例代碼如下:

// authn-examples/jwt/golang-jwt/main.go

import (
    "fmt"
    "time"

    jwt "github.com/golang-jwt/jwt/v5"
)

type MyCustomClaims struct {
    Sub                  string `json:"sub"`
    Name                 string `json:"name"`
    jwt.RegisteredClaims        // use its Subject and IssuedAt
}

func main() {
    mySigningKey := []byte("iamtonybai")

    // Create claims with multiple fields populated
    claims := MyCustomClaims{
        Name: "John Doe",
        Sub:  "1234567890",
        RegisteredClaims: jwt.RegisteredClaims{
            IssuedAt: jwt.NewNumericDate(time.Unix(1516239022, 0)), //  1516239022
        },
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    ss, _ := token.SignedString(mySigningKey)
    fmt.Println(ss)

    _, err := verifyToken(ss, "iamtonybai")
    if err != nil {
        fmt.Println("invalid token:", err)
        return
    }

    fmt.Println("valid token")
}

這段代碼中還包含了一個對jwt token驗證合法性的函數verifyToken,服務端每次收到客戶端請求中攜帶的token時,都可以使用verifyToken來驗證token是否合法,下面是verifyToken的實現邏輯:

// authn-examples/jwt/golang-jwt/main.go

// verifyToken 驗證JWT函數
func verifyToken(tokenString, key string) (*jwt.Token, error) {
    // 解析Token
    token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
        return []byte(key), nil
    })

    if err != nil {
        return nil, err
    }

    // 驗證簽名
    if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
        return nil, jwt.ErrSignatureInvalid
    }

    return token, nil
}

服務端驗證token的邏輯是先解析token,得到header、payload對應的base64UrlEncoded后的結果,然后用key重新生成簽名,對比生成的簽名與token攜帶的簽名是否一致。

那么在Web應用中如何實現基于jwt token的身份認證呢?我們繼續往下看。

4.2 使用JWT token做身份認證

在前面講解Basic Auth、Digest Auth時,Basic Auth、Digest等服務端認證方式利用了HTTP Header的Authorization字段,基于JWT token的認證也是基于Authorization字段,只不過前綴從Basic、Digest換成了Bearer:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2OTc4NjE5MzIsInVzZXJuYW1lIjoiYWRtaW4ifQ.go6NhfmYPZbtHEuJ1oULG890neo0yVdtFJwfAvHhxyE

基于JWT token的身份認證方式的客戶端與服務端的交互流程如下圖:

圖片圖片

在這幅示意圖中,客戶端先用basic auth方式登錄服務端,服務端驗證通過后,在登錄應答中寫入一個jwt token作為后續客戶端訪問服務端其他功能的依據。客戶端從登錄應答的包體中解析出jwt token后,可以將該token存放在LocalStorage中,然后在后續的發向該服務端的所有請求中都帶上這個jwt token。服務端對這些請求都會校驗其攜帶的jwt token,只有驗證通過的請求才能被正確處理。

下面來看看對應示意圖的示例源碼,先來看一下客戶端:

// authn-examples/jwt-authn/client/main.go

func main() {
 client := &http.Client{}
 req, _ := http.NewRequest("POST", "http://server.com:8080/", nil)

 // 發送默認請求
 response, err := client.Do(req)
 if err != nil {
  fmt.Println(err)
  return
 }

 // 解析響應頭
 authHeader := response.Header.Get("WWW-Authenticate")
 loginReq, _ := http.NewRequest("POST", "http://server.com:8080/login", nil)
 username := "admin"
 password := "123456"

 // 判斷認證類型
 if !strings.Contains(authHeader, "Basic") {
  // 不支持的認證類型
  fmt.Println("Unsupported authentication type:", authHeader)
  return
 }

 // 使用Basic Auth, 添加Basic Auth頭
 loginReq.SetBasicAuth(username, password)
 response, err = client.Do(loginReq)

 fmt.Println(response.StatusCode)

 // 從響應包體中獲取服務端分配的jwt token
 defer response.Body.Close()
 body, err := io.ReadAll(response.Body)
 if err != nil {
  fmt.Println(err)
  return
 }

 token := string(body)
 fmt.Println("token=", token)

 // 基于token訪問服務端其他功能
 apiReq, _ := http.NewRequest("POST", "http://server.com:8080/calc", nil)
 apiReq.Header.Set("Authorization", "Bearer "+token)
 response, err = client.Do(apiReq)
 fmt.Println(response.StatusCode)
 defer response.Body.Close()
 body, err = io.ReadAll(response.Body)
 if err != nil {
  fmt.Println(err)
  return
 }
 fmt.Println(string(body))
}

客戶端的操作流程與示意圖一樣,先用basic auth登錄server,通過驗證后,拿到服務端生成的token。后續到該服務端的所有請求只需在Header中帶上token即可。

服務端的代碼如下:

// authn-examples/jwt-authn/server/main.go

func main() {
 // 創建一個基本的HTTP服務器
 mux := http.NewServeMux()

 username := "admin"
 password := "123456"
 key := "iamtonybai"

 // 針對/的handler
 mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
  // 返回401 Unauthorized響應
  w.Header().Set("WWW-Authenticate", "Basic realm=\"server.com\"")
  w.WriteHeader(http.StatusUnauthorized)
 })

 // login handler
 mux.HandleFunc("/login", func(w http.ResponseWriter, req *http.Request) {
  // 從請求頭中獲取Basic Auth認證信息
  user, pass, ok := req.BasicAuth()
  if !ok {
   // 認證失敗
   w.WriteHeader(http.StatusUnauthorized)
   return
  }

  // 驗證用戶名密碼
  if user == username && pass == password {
   // 認證成功,生成token
   token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
    "username": username,
    "iat":      jwt.NewNumericDate(time.Now().Add(time.Hour * 24)),
   })
   signedToken, _ := token.SignedString([]byte(key))
   w.Write([]byte(signedToken))
  } else {
   // 認證失敗
   http.Error(w, "Invalid username or password", http.StatusUnauthorized)
  }
 })

 // calc handler
 mux.HandleFunc("/calc", func(w http.ResponseWriter, req *http.Request) {
  // 讀取并校驗jwt token
  token := req.Header.Get("Authorization")[len("Bearer "):]
  fmt.Println(token)
  if _, err := verifyToken(token, key); err != nil {
   // 認證失敗
   http.Error(w, "Invalid token", http.StatusUnauthorized)
   return
  }
  w.Write([]byte("invoke calc ok"))
 })

 // 監聽8080端口
 err := http.ListenAndServe(":8080", mux)
 if err != nil {
  log.Fatal(err)
 }
}

我們看到,除了在login handler中使用basic auth做用戶密碼驗證外,其他功能handler(如calc)中都使用token進行身份驗證。

與傳統會話式(session)認證相比,JWT是無狀態的,更適用于分布式微服務架構。與Basic auth和digest相比,jwt在使用體驗上又領先一籌。憑借其無需在服務端保存會話狀態、天生適合分布式架構、令牌內容可以自定義擴展等優勢,現階段,jwt已廣泛應用于以下場合:

  • 前后端分離的Web應用和API認證
  • 跨域單點登錄(SSO)
  • 微服務架構下服務間認證
  • 無狀態和移動應用認證

不過JWT認證方式也有不足,比如:客戶端要承擔令牌存儲成本、如果令牌泄露未及時失效可能被濫用等。

講到這里,從基本的用戶名密碼認證,到加上密碼散列的Digest認證,再到應用會話管理的Session認證,以及基于令牌的JWT認證,我們見證了認證機制的不斷進步和發展。

這些方法主要依賴賬號密碼這單一要素,提供了不同程度的安全性。但是隨著互聯網的快速發展,開發人員也在考慮改善用戶名密碼這種方式的使用體驗,一些一次性密碼認證方式便走入了我們的生活。接下來我們就來簡單說一下一次性密碼驗證。

5. 基于一次性密碼驗證

一次性密碼(One Time Password, OTP)是一種只能使用一次的密碼,它在使用后立即失效。OTP生成密碼的算法基于時間,在很短的時間內(一般分鐘內或更短時間內)只能使用一次;每次驗證都需要生成和輸入新的密碼,不能重復使用。

一次性密碼的優勢主要有以下幾點:

  • 安全性高:一次性密碼只能使用一次,因此即使攻擊者獲得了密碼,也無法重復使用。
  • 易用性強:一次性密碼通常是數字或字母組成的短語,易于記憶和輸入。
  • 成本低:一次性密碼的生成和驗證成本相對較低。

信息論已經從理論上證明了:一次性密碼本是無條件安全的,在理論上是無法破譯的。不過現實中,還沒有一種理想的一次性密碼,大多數一次性密碼還處于身份認證的輔助地位,多作為第二要素。

短信驗證碼就是一種我們生活中常見的一次性密碼,它是利用移動運營商的短信通道傳輸的一次性密碼。短信驗證碼通常由6位數字組成,有效期為幾分鐘,并且只能使用一次,通過短信發送給用戶,非常方便用戶使用,用戶無需有記住密碼的煩惱。

短信驗證碼的工作流程如下:

  • 客戶端發起認證請求,如登錄或注冊;
  • 服務器生成6位隨機數字作為驗證碼,通過文本短信發送到用戶注冊的手機號;
  • 用戶接收短信并輸入驗證碼進行驗證;
  • 服務器通過時間戳驗證此驗證碼是否有效(一般在5分鐘內)。
  • 驗證碼只能使用一次,服務器會將此條記錄標記為使用。

短信驗證碼的優勢是方便快捷。目前國內大多數主流Web應用都支持手機驗證碼登錄。短信驗證碼通常用于以下場景:

  • 用戶注冊
  • 用戶登錄
  • 支付或交易
  • 輔助密碼找回等

不過手機驗證碼這種一次性密碼的安全性相對較低,因為短信可以被截獲,攻擊者可以通過截獲短信來獲取驗證碼。

除短信驗證碼外,還有其他常見的OTP實現形式:

  • 手機應用軟件OTP:使用專門的手機APP軟件生成OTP碼,如Google Authenticator、Microsoft Authenticator等。
  • 電子郵件OTP:類似短信驗證碼,但通過郵件發送6-8位數字驗證碼到用戶注冊的郵箱。
  • 語音驗證碼OTP:服務端調用第三方語音平臺,使用文本到語音功能給用戶自動撥打認證電話,提示驗證碼。

總體來說,OTP越來越多地被用到用戶身份認證上來,隨著以后技術的進步,其應用的廣度和深度會進一步擴大,安全性也會得到進一步提升。基于傳統密碼的認證方式早晚會被扔到歷史的舊物箱中。一些大廠,如Google都在研究替代傳統密碼的技術,比如Passkey[13]等,一些Web標準組織也在做無密碼認證的規范,比如WebAuthn[14]等。

6. 小結

就寫到這里吧,篇幅有些長了,關于OAuth、OpenID等身份認證技術就不在這里寫了,后續找機會單獨梳理。

本文我們介紹了多種Web應用的身份認證技術方案,各種認證技術會依據對安全性、使用性和擴展性的不同需求而存在和發展。了解每種技術的原理和優劣勢,可幫助我們更好地選擇適合的方案。

首次梳理這么多Web應用身份認證的資料,可能有些描述并不完全正確,歡迎指正。在撰寫本文時,大語言模型幫助編寫部分文字素材和代碼。

本文示例所涉及的Go源碼可以在這里[15]下載。

7. 參考資料

  • 《API安全實戰》[16] - https://book.douban.com/subject/36039150/
  • 《API安全技術與實戰》[17] - https://book.douban.com/subject/35429043/
  • 《深入淺出密碼學》[18] - https://book.douban.com/subject/36179106/
  • Web Authentication Methods Compared[19] - https://testdriven.io/blog/web-authentication-methods/
  • 認證:系統如何正確分辨操作用戶的真實身份?[20] - https://time.geekbang.org/column/article/329954
  • 如何實現零信任網絡下安全的服務訪問?[21] - https://time.geekbang.org/column/article/345593
  • 憑證:系統如何保證與用戶之間的承諾是準確完整且不可抵賴的?[22] - https://time.geekbang.org/column/article/333272
  • 谷歌正推出Passkey,密碼將成歷史[23] - https://blog.google/technology/safety-security/the-beginning-of-the-end-of-the-password/
  • What is authentication?[24] - https://www.microsoft.com/zh-cn/security/business/security-101/what-is-authentication
  • Authentication(wikipedia)[25] - https://en.wikipedia.org/wiki/Authentication.html
  • RFC 7617: The 'Basic' HTTP Authentication Scheme[26] - https://datatracker.ietf.org/doc/html/rfc7617
  • RFC 7616: HTTP Digest Access Authentication[27] - https://datatracker.ietf.org/doc/html/rfc7616
  • RFC 7519: JSON Web Token(JWT)[28] - https://datatracker.ietf.org/doc/html/rfc7519
  • Introduction to JSON Web Tokens[29] - https://jwt.io/introduction


責任編輯:武曉燕 來源: 白明的贊賞賬戶
相關推薦

2023-12-25 08:04:42

2023-11-20 08:02:49

2023-06-30 17:52:00

WebDjagno框架

2023-10-31 07:37:02

2017-12-05 14:24:31

應用綁定域名

2016-11-07 10:18:13

2019-08-28 09:04:02

Go語言Python操作系統

2015-08-12 14:37:21

LinuxIRC

2012-06-04 09:36:50

2012-09-03 14:21:07

2020-09-28 15:49:25

Python編程語言工具

2018-12-24 17:52:39

身份認證桌面登錄安全

2009-06-27 10:59:04

2023-12-03 18:30:12

2021-10-24 08:15:44

Web身份認證測試框架

2012-11-28 09:55:35

2021-05-07 16:19:36

異步編程Java線程

2021-01-19 11:56:19

Python開發語言

2010-09-25 14:48:55

SQL連接

2012-09-10 10:40:18

IBMdw
點贊
收藏

51CTO技術棧公眾號

欧美日韩mp4| 99精品热视频| 九九热这里只有精品免费看| 久久久久久久人妻无码中文字幕爆| 后进极品白嫩翘臀在线播放| av午夜精品一区二区三区| 欧美野外猛男的大粗鳮| 正在播放国产对白害羞| av一级亚洲| 色视频成人在线观看免| 黄色一级视频播放| 色久视频在线播放| 麻豆国产91在线播放| 欧美精品成人91久久久久久久| 在线免费观看污视频| 素人啪啪色综合| 亚洲一本大道在线| 日韩一区免费观看| 精品欧美一区二区精品少妇| 久久久天天操| 日韩最新在线视频| 国产高清自拍视频| a一区二区三区亚洲| 都市激情亚洲色图| 精品国产一区二区三区在线| 精品一二三区视频| 国产成人综合亚洲网站| 国产精品爽黄69| 日操夜操天天操| 婷婷激情综合| 国产亚洲视频在线观看| 高清中文字幕mv的电影| 亚洲精品三区| 欧美午夜宅男影院| 男人靠女人免费视频网站| 中文字幕中文字幕在线十八区| 久久久久久久久久久黄色| 成人羞羞视频免费| 92久久精品一区二区| 日韩国产精品大片| 欧美亚洲在线播放| 国产无码精品一区二区| 在线免费观看日本欧美爱情大片| 亚洲无亚洲人成网站77777| 久久一区二区电影| 久久黄色影视| 日韩欧美一区二区视频| 中文字幕资源在线观看| 成人看片毛片免费播放器| 色一情一乱一乱一91av| 亚洲美免无码中文字幕在线 | 欧美大片在线观看一区二区| 手机看片一级片| 国产精品毛片久久久久久久久久99999999| 午夜精品aaa| 被灌满精子的波多野结衣| 欧美女同一区| 亚洲曰韩产成在线| 久久综合久久久久| 国产乱妇乱子在线播视频播放网站| 亚洲色图欧美激情| 69精品丰满人妻无码视频a片| 二区在线播放| 亚洲久本草在线中文字幕| 亚洲五码在线观看视频| 在线观看电影av| 亚洲狠狠丁香婷婷综合久久久| 日本国产中文字幕| 欧美xxxxhdvideosex| 亚洲一二三区在线观看| 日韩av黄色网址| 亚洲成人激情社区| 欧美日韩在线三级| 青青草久久伊人| 精品国产亚洲一区二区三区在线 | 你懂的视频欧美| 国产一区二区美女视频| 国产一区二区三区视频播放| 国产精品久久久久久| 超在线视频97| 久久精品视频8| 国产亚洲精品久久久久婷婷瑜伽| 日本不卡视频在线播放| 国产亚洲久一区二区| 韩国成人精品a∨在线观看| 亚洲综合日韩中文字幕v在线| www男人的天堂| 91一区在线观看| 亚洲国产欧美不卡在线观看| 午夜伦理大片视频在线观看| 五月天亚洲精品| 91网址在线播放| 年轻的保姆91精品| 日韩精品福利网站| 四虎地址8848| 在线视频亚洲| 成人久久精品视频| 香蕉久久一区二区三区| 国产精品国产成人国产三级 | 成人三级网址| 精品国产999| av中文字幕网址| 欧美高清视频看片在线观看| 中文字幕不卡在线视频极品| 久久久久无码精品国产| 日本中文在线一区| 国产二区不卡| 日本在线看片免费人成视1000| 亚洲国产你懂的| 亚洲综合av在线播放| 日韩av网站在线免费观看| 伦理中文字幕亚洲| 黄色av一区二区| 成人在线视频首页| 亚洲人成网站在线观看播放| av影院在线免费观看| 欧美人妇做爰xxxⅹ性高电影| 污网站免费观看| 无需播放器亚洲| 日产精品99久久久久久| 国产综合在线播放| 最新国产の精品合集bt伙计| 超碰影院在线观看| 精品欧美午夜寂寞影院| 欧美精品在线第一页| 亚洲国产精品va在线看黑人动漫| 亚洲欧美一区二区三区不卡| 蜜桃精品噜噜噜成人av| 欧美激情一区二区三级高清视频| 影音先锋黄色网址| 久久久久久一二三区| 男人天堂av片| 年轻的保姆91精品| 久久久精品在线观看| 国产精品成人无码| 久久久高清一区二区三区| 加勒比成人在线| 精品一区二区三区中文字幕| 色偷偷亚洲男人天堂| 男人天堂av在线播放| 成人av免费网站| 日本人妻伦在线中文字幕| 韩国理伦片久久电影网| 亚洲天堂网站在线观看视频| 亚洲第一在线播放| 不卡av在线网| 欧美精品卡一卡二| 91久久精品无嫩草影院| 久久av在线播放| 97人妻精品一区二区三区| 日本一区二区久久| 少妇性l交大片| 韩日一区二区三区| 国产精品久久久久久久久久久久久 | 国产精品综合网站| av在线免费观看网站| 日本韩国精品在线| 精品无码国产污污污免费网站| 亚洲欧美日韩国产一区二区| 久久一区二区三区欧美亚洲| 高清毛片在线观看| 日韩精品视频中文在线观看| 日本中文在线播放| 久久美女艺术照精彩视频福利播放| 欧美在线观看www| 蜜臀久久99精品久久一区二区 | 99国内精品久久久久| 久久天天躁狠狠躁夜夜躁2014| 91tv国产成人福利| 一区二区三区中文字幕电影| 亚洲少妇中文字幕| 国产亚洲精品自拍| 亚洲成人自拍| 9.1麻豆精品| 欧美日韩成人在线播放| 天堂av一区二区三区| 欧美性xxxxx| xxxxx99| 国产乱色国产精品免费视频| 国产手机免费视频| 九九精品久久| 国产日韩欧美中文| 久久99亚洲网美利坚合众国| 亚洲精品国产品国语在线| 亚洲欧美日韩一区二区三区四区| 17c精品麻豆一区二区免费| 少妇伦子伦精品无吗| 久久激情视频| 熟女熟妇伦久久影院毛片一区二区| 欧洲大片精品免费永久看nba| 午夜精品福利在线观看| 在线国产91| 欧美成人在线直播| 日韩av免费播放| 亚洲乱码日产精品bd| av网站免费在线播放| 九九精品视频在线看| 野外做受又硬又粗又大视频√| 国产欧美日韩精品一区二区三区| 91免费综合在线| 亚洲欧洲高清| 另类美女黄大片| 国产一区二区三区不卡在线| 日韩亚洲欧美在线| 日韩xxx视频| 亚洲一区二区偷拍精品| 黄色国产在线播放| 97se亚洲国产综合自在线观| 911福利视频| 久久久久99| 久青草视频在线播放| 日韩久久精品网| 精品一区2区三区| 国产精品视频首页| 国产成人+综合亚洲+天堂| 2024最新电影免费在线观看| 在线精品高清中文字幕| 天天色棕合合合合合合合| 91麻豆精品国产综合久久久久久| 91video| 性欧美疯狂xxxxbbbb| 搜索黄色一级片| 国产清纯在线一区二区www| 久久性爱视频网站| 国精品**一区二区三区在线蜜桃| 久草综合在线观看| 国产欧美大片| av无码久久久久久不卡网站| 亚洲最新色图| 亚洲丰满在线| 精品久久影院| 欧美日韩综合久久| 台湾佬综合网| 久久av二区| 精品国产导航| 国产精品日韩欧美一区二区三区| 精品久久免费| 成人免费淫片aa视频免费| xxxxx.日韩| 国产精品678| av一区在线| 国产成人综合亚洲| 午夜精品成人av| 热久久这里只有精品| 国产美女高潮在线| 午夜精品久久久久久99热软件| 五月婷婷视频在线观看| 欧美刺激性大交免费视频| av在线麻豆| 欧美精品在线网站| 青草视频在线免费直播 | 欧美成人777| 亚洲精品视频在线观看网站| 亚洲欧美小视频| 亚洲女与黑人做爰| 免费在线黄色片| 亚洲国产va精品久久久不卡综合| 国产无码精品视频| 欧美日韩免费网站| 狠狠人妻久久久久久综合| 91成人免费电影| 在线观看免费视频a| 欧美日韩国产片| 99热这里只有精品99| 日韩欧美一区二区在线视频| 人妻无码一区二区三区久久99| 亚洲第一综合天堂另类专| 神马亚洲视频| 亚洲精品动漫100p| 国产黄在线观看免费观看不卡| 亚洲午夜未删减在线观看 | 欧美精品少妇videofree| 国产福利在线免费观看| 欧美在线视频观看免费网站| 欧美性理论片在线观看片免费| 国产美女精品视频| 日本一区精品视频| 久久草视频在线看| 久久香蕉国产| 久久人妻无码一区二区| 亚洲深爱激情| 波多野结衣xxxx| 国产高清在线精品| 国产精品九九九九九| 国产精品久久久久一区| 激情综合网五月天| 一本到不卡精品视频在线观看| 伊人免费在线观看| 精品久久久久久久久久久久久久久久久| 神马一区二区三区| 色视频www在线播放国产成人 | 亚洲欧美偷拍三级| 国产成人在线播放视频| 欧美中文字幕不卡| 韩国av免费在线| 亚洲摸下面视频| av电影免费在线观看| 欧美一级大片视频| 白嫩亚洲一区二区三区| 免费国产一区| 欧美亚洲不卡| 奇米影音第四色| aaa亚洲精品| 三上悠亚在线观看视频| 精品欧美激情精品一区| 97国产精品久久久| 国产网站欧美日韩免费精品在线观看| 蜜桃视频网站在线| 欧美最猛性xxxxx免费| 日韩一区免费| 亚洲一区二区三区午夜| 亚洲综合99| 免费在线观看日韩av| 国产精品免费丝袜| 国内自拍视频在线播放| 精品噜噜噜噜久久久久久久久试看 | 黄页网站免费观看| 在线不卡免费欧美| 国产小视频在线观看| 亚州成人av在线| 日韩国产在线不卡视频| 亚洲一区二区高清视频| 久久久久中文| 自拍视频一区二区| 亚洲一区在线视频观看| 国产精品高潮呻吟av| 国产亚洲人成网站在线观看| 一区二区三区短视频| 国产精品一 二 三| 综合激情一区| 青娱乐国产精品视频| 中文字幕一区二区三中文字幕| 一级黄色av片| 亚洲日韩欧美视频一区| 理论不卡电影大全神| 国产传媒一区二区三区| 国产精品大片| 国产亚洲精品成人a| 亚洲精品高清在线| 国产精品色综合| 久久精品国产亚洲7777| 亚洲欧美在线综合| 在线观看一区欧美| 久久激情五月婷婷| 成人一区二区电影| 亚洲天堂中文字幕在线| 欧美精品一区二区久久婷婷 | 女同久久另类99精品国产| 国产freexxxx性播放麻豆| 高清不卡在线观看av| 久久中文字幕无码| 亚洲的天堂在线中文字幕| av成人影院在线| 国产一区二区不卡视频| 日韩一级精品| 成人无码www在线看免费| 色综合色狠狠天天综合色| 免费av在线电影| 国产精品亚洲美女av网站| 99九九热只有国产精品| 成人性生交视频免费观看| 亚洲精品国产成人久久av盗摄 | 欧美精品一区视频| 国产探花视频在线观看| 激情小说综合网| 老鸭窝亚洲一区二区三区| 精品无码国产污污污免费网站| 欧美系列一区二区| 182tv在线播放| 国产精品美女黄网| 性一交一乱一区二区洋洋av| 538精品视频| 337p亚洲精品色噜噜狠狠| 女同视频在线观看| 美日韩免费视频| 日韩成人一区二区| 黄色a级片在线观看| 亚洲白虎美女被爆操| 性欧美超级视频| 中文精品一区二区三区| 国产成人在线影院| 国产微拍精品一区| 中文字幕亚洲第一| 在线综合色站| 人妻无码视频一区二区三区| 中文字幕视频一区二区三区久| 亚洲第一黄色片| 日本一本a高清免费不卡| 97视频精品| 男人的天堂影院| 欧美日韩免费高清一区色橹橹| 午夜伦理在线视频| 色姑娘综合av| 国产成人久久精品77777最新版本| 亚洲第一在线播放| 大胆人体色综合| 国产一区二区三区四区五区 | 精品国产鲁一鲁****| 国产青青在线视频| 亚洲天堂精品视频|