1. 程式人生 > >從有狀態應用(Session)到無狀態應用(JWT),以及 SSO 和 OAuth2

從有狀態應用(Session)到無狀態應用(JWT),以及 SSO 和 OAuth2

從有狀態應用(Session)到無狀態應用(JWT),以及 SSO 和 OAuth2

不管用哪種方式認證使用者,都可能被中間人攻擊竊取 SessionID 或 Token,從而發生 CSRF 攻擊。解決方式就是全站 HTTPS。現在 Let’s Encrypt 已經支援免費的萬用字元 HTTPS 證書了。

0. 引子

HTTP 協議是無狀態的,要儲存使用者狀態需要額外的機制。

0.1 開始

剛開始時,多數公司使用的技術棧是:單臺雲伺服器上安裝所需的所有軟體,包括 Nginx 提供 Web 服務,MySQL 資料庫,PHP-FPM 應用程式服務。這時候使用的使用者認證協議使用最簡單的 Session。客戶端的每個請求都會攜帶 Cookie,其中儲存了 SessionID 欄位,伺服器可以通過這個 SessionID 欄位訪問到對應的 Session(例如 PHP 中的 $_SESSION

),從而識別出使用者登入狀態。Session 中還可以新增一些常用的欄位進來(比如使用者名稱、手機號等),避免對資料庫的頻繁訪問。

0.2 發展

後來,隨著使用者量增大、併發增大,單臺伺服器搞不定了,於是搞了個水平擴充套件的伺服器叢集,通過 Nginx 或 LVS 實現負載均衡。這時發現個問題,使用者登入後 Session 是儲存到叢集中的某一臺伺服器上的。要使 Session 機制可以在分散式環境下繼續工作,需要一些額外操作。而且對於現在的大前端(瀏覽器、APP、小程式)趨勢來說,Cookie 機制略顯累贅。

而這時,JWT 認證協議完全滿足需求。協議簡單清晰,花一個下午就可以搞清楚。

0.3 壯大

多產品線

公司發展過程中,產品線會慢慢增多,比如百度的貼吧、網盤、瀏覽器等。這時,需要一套單點登入機制 SSO(Single sign-on),使用者只要一次登入,就可以使用這一系列產品。SSO 描述了認證的問題。

SSO 需要一個獨立的認證中心 CAS(Central Authentication Service,中央認證服務),只有認證中心能提供登入入口,接受使用者的使用者名稱密碼等憑證,其他系統無登入入口,只接受認證中心的間接授權。這裡有個開源的 CAS:apereo CAS,其服務端用 Java 實現,客戶端支援多種語言。其架構文件可以參考 這裡

微服務

單體專案拆分成微服務後,可以更加靈活。通常所有的服務都在閘道器之後,所有請求都發送到閘道器,由閘道器統一轉發。微服務的閘道器通常實現了 OAuth,成為認證授權中心,用於判斷是否有足夠許可權。微服務之間可以通過 JWT 進行訪問鑑權,避免身份認證。

成為開放平臺

隨著公司使用者增多(假設跟微信一樣,有幾億使用者),合作企業也越來越多。如果每次都要在後臺通過人工給合作伙伴配置賬號密碼,分配許可權管理,那太麻煩了。同時,一些企業有自己的平臺,想要利用我的使用者賬號體系實現在這些平臺上的登入(授權登入)。對於使用者的圖片,一些圖片列印公司也想在經過使用者同意後,直接訪問到我伺服器上的使用者圖片,優化體驗。

總之,就是隻要使用者同意,他可以分享自己的所有資源(賬號、圖片等)。這時,就需要 OAuth2 了。這是一個授權框架,描述了各種授權的問題。

0.4 關於 authorization(授權) 和 authentication(認證)

  • authorization(授權):表示允許做某些事情
  • authentication(認證):判斷真實性

例如,使用者登入論壇時,需要先用使用者名稱和密碼認證使用者有沒有許可權登入,如果密碼正確則認證通過,登入成功。使用者登入後,判斷其角色並授予相應的許可權,例如超級管理員可以刪除所有人,版主可以刪除其版塊的帖子。

1. Session

1.1 Session 原理

最傳統的使用者認證方式。使用者首次訪問應用伺服器後建立會話,伺服器可以使用 Set-Cookie 這個 HTTP Header,將會話的 SessionID 寫入在使用者端儲存的 Cookie 中(具體的名字可以自行設定,系統中統一即可)。下次使用者再次向這個域名發請求時會攜帶所有 Cookie 資訊,包括這個 SessionID。

Session 資訊儲存在伺服器端,而用於唯一標識這個 Session 的 SessionID 則儲存在對應客戶端的 Cookie 中。SessionID 這個會話識別符號本質上是一個隨機字串,每個使用者的 SessionID 都不一樣。

Session 中可以儲存很多資訊。例如設定一個 IsLogin 欄位,使用者通過賬號密碼登入後,將這個欄位設定為 TRUE。這樣,在 Session 的有效期內(比如 2 小時),即使使用者關閉網頁,再次開啟後仍會保持登入狀態(除非使用者清理了 Cookie,導致其訪問伺服器時沒有攜帶 SessionID 欄位)。對於其他的常用欄位(如 userID、userName等)也可以新增到 Session 中,以減少資料庫的訪問壓力,但注意不要太大,因為所有使用者的會話資訊都是儲存在伺服器的記憶體中的。

1.2 通過 Fiddler 抓包分析 Session

下面的示例,基於 PHP 語言,CodeIgniter 框架。同時,省略了無關的 HTTP Header,重點分析 Session 相關欄位。

1. 首次訪問某個網站

在第一次訪問一個網站時,瀏覽器中沒有對應 Cookie 資訊,所有請求的 HTTP Header 中沒有 Cookie 這個欄位。如果應用伺服器支援會話,可以在為這個使用者建立 Session 後,通過在響應的 HTTP Header 中使用 Set-Cookie 欄位將這個會話的 SessionID 儲存到瀏覽器的 Cookie 中。可以看到我這裡對應的 SessionID 的名字是 ci_session:

-----------------------------------------請求的 HTTP Header-----------------------------------------
GET http://tuan.local.cn/ HTTP/1.1
Host: tuan.local.cn
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
If-Modified-Since: Thu, 10 May 2018 06:20:36 GMT
...

-----------------------------------------響應的 HTTP Header-----------------------------------------
HTTP/1.1 200 OK
Date: Thu, 10 May 2018 06:21:13 GMT
Content-Type: text/html; charset=UTF-8
Set-Cookie: ci_session=hfvn0lq3ct62mppl86n2du535bc4stcf; expires=Thu, 10-May-2018 08:21:13 GMT; Max-Age=7200; path=/; HttpOnly
...

這裡 Set-Cookie 中的各個欄位解釋如下,完整的中文版解釋參考 這裡

  • ci_session:SessionID,這個會話對應的伺服器上的 Session 的唯一識別符號。
  • expires:Cookie 的有效期。
  • Max-Age:Cookie 過期前的秒數。
  • path:可以在 Header 中使用這個 Cookie 的 URL 路徑,這裡表示這個域名下的所有請求都會攜帶這個 Cookie。
  • HttpOnly:表示這個 Cookie 無法通過 JavaScript 的 Document.cookie 屬性或 XMLHttpRequest 和 Request 這兩個 API 訪問,避免 XSS(cross-site scripting,跨站指令碼攻擊)。

2. 再次訪問這個網站

每次通過域名或 IP 地址訪問時,瀏覽器都會檢查是否有可用的 Cookie,如果有,則放到請求的 HTTP Header 中一同傳送到伺服器:

-----------------------------------------請求的 HTTP Header-------------------------------------------
GET http://tuan.local.cn/ HTTP/1.1
Host: tuan.local.cn
Cookie: ci_session=hfvn0lq3ct62mppl86n2du535bc4stcf
...

-----------------------------------------響應的 HTTP Header-------------------------------------------
HTTP/1.1 200 OK
Date: Thu, 10 May 2018 06:22:02 GMT
Content-Type: text/html; charset=UTF-8
...

3. 登入

登入成功之後,登入請求對應的響應會再次設定 Cookie 欄位,重新設定 Cookie 欄位的有效期。我的應用程式中設定 Session 為兩個小時的有效期:

這裡演示的是通過 AJAX 登入,所以有 Origin 和 X-Requested-With 這兩個由瀏覽器自動設定的欄位:

-----------------------------------------請求的 HTTP Header-------------------------------------------
POST http://tuan.local.cn/index/login_password HTTP/1.1
Host: tuan.local.cn
Origin: http://tuan.local.cn
X-Requested-With: XMLHttpRequest
Content-Type: application/json
Referer: http://tuan.local.cn/
Cookie: ci_session=hfvn0lq3ct62mppl86n2du535bc4stcf
...

{"Mobile":"18866668888","Password":"888666"}

-----------------------------------------響應的 HTTP Header-------------------------------------------
HTTP/1.1 200 OK
Set-Cookie: ci_session=hfvn0lq3ct62mppl86n2du535bc4stcf; expires=Thu, 10-May-2018 08:22:33 GMT; Max-Age=7200; path=/; HttpOnly
...

4. 登入後的訪問

跟正常訪問沒有區別,只是攜帶的 Cookie 中有 SessionID,且伺服器端對應的 Session 中需要(比如 IsLogin=true,自己設定)標識已登入狀態:

-----------------------------------------請求的 HTTP Header-------------------------------------------
GET http://tuan.local.cn/ HTTP/1.1
Host: tuan.local.cn
Cookie: ci_session=hfvn0lq3ct62mppl86n2du535bc4stcf
...

-----------------------------------------響應的 HTTP Header-------------------------------------------
HTTP/1.1 200 OK
Date: Thu, 10 May 2018 06:22:34 GMT
...

1.3 Session 的不足

Session 的主要問題有:

  • 伺服器壓力大:每個使用者在認證後,Session 資訊都會儲存在伺服器的記憶體中,開銷大。
  • 難以擴充套件:對於基於 Session 的分散式系統,要實現負載均衡,有兩個辦法:確保同一使用者始終訪問同一個伺服器,或在多臺伺服器之間同步 Session。對於前者,Nginx 也可以用 ip_hash 把同一來源的 IP(同一 C 段)指向後端的同一臺機器。對於後者則需要通過 Session Sticky 機制在多臺伺服器之間同步 Session(例如 Nginx 的擴充套件模組 nginx-sticky-module。假設 Session 儲存在 A 伺服器上,而使用者訪問了 B 伺服器,則可以將 Session 從 A 同步到 B,但是如果儲存 Session 的 A 伺服器掛掉,還是會導致使用者掉線)。

還有,就是目前大前端的發展,除了瀏覽器外,各種 APP、小程式層出不窮,而非瀏覽器下環境下避免使用 Cookie 可能會更簡單。

2. JWT

JWT 官網的詳細介紹 
Larval + Vue 案例

Session 之所以這麼麻煩,是因為需要在伺服器端儲存資訊,那我把資訊儲存在客戶端,不就可以避免這個麻煩了嘛。JWT 就是這麼個思路,伺服器端儲存加密機制及金鑰,對使用者指定欄位進行加密後的字串儲存在客戶端,使用者下次請求時攜帶加密前的欄位和加密後的字串,如果跟伺服器加密結果匹配,則認為登入成功。

2.1 JWT 原理

JWT(JSON web token)是一種認證協議,可以釋出接入令牌(Access Token,保持在客戶端)並對釋出的簽名接入令牌進行驗證。令牌(Token)本身包含一系列宣告,應用程式可以根據這些宣告限制使用者對資源的訪問。

JWT 由三段資訊構成的:

  • header
  • payload
  • signature

JWT 示例:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjRmMWcyM2ExMmFhIn0.eyJpc3MiOiJodHRwOlwvXC9leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHA6XC9cL2V4YW1wbGUub3JnIiwianRpIjoiNGYxZzIzYTEyYWEiLCJpYXQiOjE1MjU5NDM4MTksIm5iZiI6MTUyNTk0Mzg3OSwiZXhwIjoxNTI1OTQ3NDE5LCJ1aWQiOjF9.jL-Hrl8obZlLGutjr-nVPCSoF2ObFh-rWfSwSZxoxzs
  • 1

1. header 部分

Header 部分用於宣告協議型別和加密方式。

上面的 JWT 示例的 header 部分經過 base64_decode 後得到原始 JSON 字串,內容如下:

{
    "typ":"JWT",
    "alg":"HS256",
    "jti":"4f1g23a12aa"
}

其中,typ 內容固定為 JWT,alg 表示加密演算法,這裡使用的是 HMAC SHA256。

2. payload 部分

payload 部分用於存放負載,將明文資訊經過 base64 編碼後儲存,未經加密,不可儲存敏感資訊。包括以下三種:

  • JWT 標準中註冊的宣告
  • 公共宣告
  • 私有宣告

JWT 標準中註冊的宣告(不強制使用)有以下幾種,完整版可以 參考這裡

  • iat:Issued At,簽發時間
  • iss:Issuer,JWT 簽發者
  • sub:subject,JWT 所面向的訂閱者,每個 Issuer 範圍內是唯一的
  • aud:Audience,JWT 的接收方
  • exp:Expiration Time,過期時間,這個過期時間必須要大於簽發時間
  • nbf:定義在什麼時間之前,該 JWT 都是不可用的.
  • jti:JWT 的唯一身份標識,主要用來作為一次性 Token,避免重放攻擊

上面 JWT 示例中的 payload 部分對應的 JSON 字串為:

{
    "iss":"http:\/\/example.com",
    "aud":"http:\/\/example.org",
    "jti":"4f1g23a12aa",
    "iat":1525943995,
    "nbf":1525944055,
    "exp":1525947595,
    "userID":6666,
    "userName":"kika",
    "userSex":"m"
}

這個 payload 中添加了幾個自定義欄位。

3. signature 部分

將 header 和 payload 經過 base64 編碼後,用 . 句點拼接成一個字串,通過 HMACSHA256(Java 的方法)或 hash_hmac(PHP 的方法),使用指定金鑰加密這個字串得到 signature。

JAVA:

sig = HMACSHA256(base64UrlEncode(header) + "." +  base64UrlEncode(payload),  secret);
  •  

PHP:

$sig = hash_hmac('sha256', base64_encode($header) + "." +  base64_decode($payload), $secret);
  •  

JWT 支援兩種簽名方式:

  • 金鑰:基於字串,簡單,安全性低
  • RSA 和 ECDSA 簽名:基於公鑰和私鑰,需要先生成私鑰檔案,簽名時指定這個檔案的位置

2.2 JWT 特點

  • 資訊基於 base64 編碼轉換為 ASCII 碼,傳輸可靠。
  • 資訊是不加密儲存的,不可存敏感資訊。
  • JWT 本質上是通過時間換空間,伺服器不儲存使用者狀態資訊,但是每個使用者請求都會消耗 CPU 時間來驗證 Token。
  • 基於 Token 的鑑權機制保持了 HTTP 協議的無狀態型,從而實現更簡單的水平擴充套件。
  • 需要在伺服器端額外程式設計(Session 則不用)。
  • 生成簽名欄位時,支援使用金鑰字串簽名(安全性較低),也支援使用 RSA、ECDSA 私鑰簽名。

使用者登陸後,可以把一些常用欄位(使用者標識,是否是管理員,許可權有哪些等等可以公開的資訊)用 JWT 編碼儲存在 Cookie 中,每次伺服器讀取到 Cookie 後就可以解析到當前使用者對應的資訊,減小資料庫壓力。也可以用 Authorization: Bearer <jwttoken> 的方式通過 HTTP Header 僅傳送 JWT 的 Token。

2.3 JWT 工作流程

  1. 使用者通過賬號密碼發起登入請求
  2. 伺服器驗證通過後,設定 header 和 payload,並得到加密後的簽名,然後將這三部分作為 Token 傳送給使用者
  3. 客戶端儲存 Token,並在每個請求中附加這個 Token
  4. 如果請求攜帶了 Token,伺服器會驗證這個 Token 並根據驗證結果進行不同處理

傳送請求時,Token 放在請求的 HTTP Header 中。另外,如果發生跨域,例如 www.xx.com 下發出到 api.xx.com 的請求,需要在服務端開啟 CORS(跨域資源共享):

Access-Control-Allow-Origin: *
  •  

2.4 通過 Fiddler 抓包分析 JWT

下面的示例,基於 PHP 語言,CodeIgniter 框架。同時,省略了無關的 HTTP Header,重點分析 JWT 相關欄位。

1. 登入成功,Token 建立並設定客戶端的 Cookie

-----------------------------------------請求的 HTTP Header-------------------------------------------
GET http://jwt.com/welcome/create_token HTTP/1.1
Host: jwt.com
...

-----------------------------------------響應的 HTTP Header-------------------------------------------
HTTP/1.1 200 OK
Date: Fri, 11 May 2018 02:27:19 GMT
Set-Cookie: jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjRmMWcyM2ExMmFhIn0.eyJpc3MiOiJodHRwOlwvXC9leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHA6XC9cL2V4YW1wbGUub3JnIiwianRpIjoiNGYxZzIzYTEyYWEiLCJpYXQiOjE1MjYwMDU2MzksIm5iZiI6MTUyNjAwNTY5OSwiZXhwIjoxNTI2MDA5MjM5LCJ1c2VySUQiOjY2NjYsInVzZXJOYW1lIjoia2lrYSIsInVzZXJTZXgiOiJtIn0.MvYG6L71mM_AJj5FT4--RzCluIQ__nqgYSe9RTj8VCk; expires=Fri, 11-May-2018 04:27:19 GMT; Max-Age=7200; path=/
Content-Length: 1052
...
  •  

2. 使用者再次訪問時,攜帶 Cookie

伺服器端從 Cookie 中提取 jwt 這個欄位後驗證簽名,如果通過驗證則認為內容可靠,解析其中的內容並以此決定使用者登入狀態、許可權等:

-----------------------------------------請求的 HTTP Header-------------------------------------------
GET http://jwt.com/welcome/ HTTP/1.1
Host: jwt.com
Cookie: jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjRmMWcyM2ExMmFhIn0.eyJpc3MiOiJodHRwOlwvXC9leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHA6XC9cL2V4YW1wbGUub3JnIiwianRpIjoiNGYxZzIzYTEyYWEiLCJpYXQiOjE1MjYwMDU2MzksIm5iZiI6MTUyNjAwNTY5OSwiZXhwIjoxNTI2MDA5MjM5LCJ1c2VySUQiOjY2NjYsInVzZXJOYW1lIjoia2lrYSIsInVzZXJTZXgiOiJtIn0.MvYG6L71mM_AJj5FT4--RzCluIQ__nqgYSe9RTj8VCk
...

通過 HTTP Header 欄位 Authorization 實現

1. 登入成功,伺服器建立並設定客戶端的 Authorization 這個 HTTP Header

-----------------------------------------請求的 HTTP Header-------------------------------------------
GET http://jwt.com/welcome/create_token HTTP/1.1
Host: jwt.com
...

-----------------------------------------響應的 HTTP Header-------------------------------------------
HTTP/1.1 200 OK
Date: Fri, 11 May 2018 02:35:19 GMT
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjRmMWcyM2ExMmFhIn0.eyJpc3MiOiJodHRwOlwvXC9leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHA6XC9cL2V4YW1wbGUub3JnIiwianRpIjoiNGYxZzIzYTEyYWEiLCJpYXQiOjE1MjYwMDc3NTQsIm5iZiI6MTUyNjAwNzgxNCwiZXhwIjoxNTI2MDExMzU0LCJ1c2VySUQiOjY2NjYsInVzZXJOYW1lIjoia2lrYSIsInVzZXJTZXgiOiJtIn0.CBsw7_rDC-GeJBob2JwCOITp7L80g_VT9KUtSVmKYKY
Host: jwt.com

2. 使用者再次訪問時,攜帶 Authorization

-----------------------------------------請求的 HTTP Header-------------------------------------------
GET http://jwt.com/welcome/ HTTP/1.1
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjRmMWcyM2ExMmFhIn0.eyJpc3MiOiJodHRwOlwvXC9leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHA6XC9cL2V4YW1wbGUub3JnIiwianRpIjoiNGYxZzIzYTEyYWEiLCJpYXQiOjE1MjYwMDc3NTQsIm5iZiI6MTUyNjAwNzgxNCwiZXhwIjoxNTI2MDExMzU0LCJ1c2VySUQiOjY2NjYsInVzZXJOYW1lIjoia2lrYSIsInVzZXJTZXgiOiJtIn0.CBsw7_rDC-GeJBob2JwCOITp7L80g_VT9KUtSVmKYKY
Host: jwt.com

後端伺服器對這個 Authorization 進行判斷即可。

2.5 示例(基於 PHP)

對於 PHP,可以使用的 JWT 庫有 jwtjwt-auth。這裡以第一個 jwt 為例,具體操作請結合所使用語言及框架和安裝的 JWT 庫。

2.5.1 使用 composer 安裝 JWT 庫

composer require lcobucci/jwt
  •  

注意,PHP 版本需要 5.5+,同時需要開啟 OpenSSL 擴充套件。

2.5.2 通過 JWT 庫生成 Token

使用祕鑰簽名

use Lcobucci\JWT\Parser;
use Lcobucci\JWT\Builder;
use Lcobucci\JWT\Signer\Hmac\Sha256;

public function create_token() {
    $builder = new Builder();
    $signer = new Sha256();

    // 設定簽發者
    $builder->setIssuer('http://xx.com');
    // 設定接收者
    $builder->setAudience('http://xx.com');
    // 設定 ID,可以用來區分
    $builder->setId('4f1g23a12aa', true);
    // 設定簽發時間
    $builder->setIssuedAt(time());
    // 在 60 秒內該 token 無法使用
    $builder->setNotBefore(time() + 60);
    // 設定過期時間位 2 小時
    $builder->setExpiration(time() + 7200);
    // 設定自定義的 payload 資訊
    $builder->set('userID', 6666);
    $builder->set('userName', 'kika');
    $builder->set('userSex', 'm');
    // sha256 簽名,金鑰字串可以自定義
    $builder->sign($signer, 'signatureString');
    // 獲取生成的token
    $token = $builder->getToken();

    // 可以通過 Cookie 傳輸
    set_cookie('jwt', $token, 7200);
    // 也可以通過 HTTP Header 傳輸,在前端儲存 token 後新增到 HTTP Header 即可:Authorization: Bearer xx.xx.xx

    // 檢視欄位內容
    $token = explode('.', $token);
    echo base64_decode($token[0]).'<br/>';
    echo base64_decode($token[1]).'<br/>';
}

使用 RSA 和 ECDSA 簽名

把上面使用字串加密的這一行:

    $builder->sign($signer, 'signatureString');
  • 1

替換為使用金鑰檔案加密即可,需要提供私鑰地址:

    $builder->sign($signer, $keychain->getPrivateKey('私鑰地址'));
  • 1

在每一個請求頭裡加入 Authorization,並加上 Bearer:

fetch('api/user', {
  headers: {
    'Authorization': 'Bearer ' + token
  }
})

2.5.4 驗證簽名

通過 Cookie 傳輸 JWT 資訊:

if ($token = get_cookie('jwt')) {
    $rs = $this->verify_token($token);
    if ($rs) {
        echo 'you have right jwt<br />';
    } else {
        echo 'error<br />';
    }
}

通過 HTTP Header 傳輸 JWT 資訊:

$headers = apache_request_headers();
if (!empty($headers['Authorization']) && $token = $headers['Authorization']) {
    $token = substr($token, strpos($token, 'Bearer ') + 7);
    $rs = $this->verify_token($token);
    if ($rs) {
        echo 'you have right jwt from Authorization<br />';
    } else {
        echo 'error Authorization<br />';
    }
}

2.5.5 提取資料

直接從 $token 中獲取所有資料:

public function get_claims ($token) {
    $parser = new Parser();
    $parse = $parser->parse($token);
    return $parse->getClaims();
}

也可以獲取單條資料:

$parse->getClaim('aud');