如何驗證使用者身分:使用 JWT

如何驗證使用者身分:使用 JWT

驗證與授權是開發網路應用時一定會遇到的問題,前者指的是確認使用者身分,讓 Server 明白請求者是真正的使用者,而不是其他人假冒;後者指的是該使用者有權限進行操作。JWT 處理的主要是前一個問題。

先來看看 JWT 出來前的做法。在傳統技術中,使用者會在登入時輸入帳號密碼,Server 由資料庫驗證無誤後,創建一組 Session ID,放入回應的 Cookie ,Client 後續請求都會在 Cookie 帶上 Session ID,方便 Server 檢驗。

由於只看 Session ID 無法說明使用者身分正確,還需要看該 Session ID 是否有儲存在 Server,而 Server 的數據通常儲存在資料庫,一來一往之間,就會造成 Server 端額外的開銷。JWT 只需要在 Server 儲存一組 Secret,即可對應不同的使用者,對比舊方法來說,能降低 Server 的負擔,已經成為當前主流的網路驗證方案。

本文會講解 JWT 的原理並用 Node.js 搭配前端頁面,寫個簡單的網頁應用,需要 Clone 程式碼的,可以到這裡

Introduction

JWT 全名是 JSON Web Token,由字面來看,它是使用 JSON 格式的一組網路應用 Token。Token 可以解釋成代幣,當 Server 確認使用者身分後,會發行一枚代幣給使用者,只要持有這枚代幣的人,Server 都會將它當成正規的使用者看待。

JWT 有兩組不同的實作,分別是 JWS 和 JWE,通常用到的會是 JWS。S 指的是 Signatures,代表這枚 JWT 中有簽章資訊,可用於保證訊息的正確性。它像是鈔票上的浮水印,只要看到浮水印,就知道這張鈔票不是偽鈔。

JWT 可以由三個部份組成,分別是

  1. header
  2. payload
  3. signature/encryption data

Header 承載自我聲明的訊息,例如使用的演算法,用 JSON 來表示的話,會像是

{"alg":"HS256","typ":"JWT"}

Payload 則是內容,同樣用 JSON 表示

{"user":"user","iat":1604146546,"exp":1604232946}

這兩組會用 Base64 編碼成

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 eyJ1c2VyIjoidXNlciIsImlhdCI6MTYwNDE0NjU0NiwiZXhwIjoxNjA0MjMyOTQ2fQ

而 Signature 則會用加密演算法,對前面兩組訊息簽章,得到

gGyZTuVLTsibYW2QgUsXIU-66Z7NrqWlRMAyj_qx63s

將三組資訊放在一起,用 . 隔開,就成為 JWT 最後的樣子

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoidXNlciIsImlhdCI6MTYwNDE0NjU0NiwiZXhwIjoxNjA0MjMyOTQ2fQ.gGyZTuVLTsibYW2QgUsXIU-66Z7NrqWlRMAyj_qx63s

由於 Payload 中有紀錄 user 的身分,而該身分又是經由 Server 簽名過的,因此只要看到這枚 JWT,Server 就能知道該請求由真實的使用者發送。

Implement Server Side

明白原理後,來看看如何實現。既然 JWT 牽涉到 Server 跟 Client 間的訊息交換,就需要分別實現兩邊的程式。

先來看 Server 端,專案架構是

.
├── README.md
├── index.js
├── package-lock.json
├── package.json
├── public
│   └── css
│       └── main.css
└── views
    └── index.ejs

index.js 負責後端邏輯,public 內放置靜態資源,用於前端。

為開發方便,安裝 node.js 的熱更新套件 nodemon,它可以讓後端程式碼更新時,立即刷新服務

npm install nodemon -g

接著安裝 JavaScript 的 JWT 套件跟 express

npm install jsonwebtoken --save
npm install express --save

使用 express 來處理後端程式

const express = require('express');
const app = express();
const bodyParser = require('body-parser');
const jwt = require('jsonwebtoken')

const SECRET = 'secret'

app.set('view engine', 'ejs');

app.use(express.static(__dirname + '/public'));
app.use(bodyParser.json({ extended: false }));
app.use(bodyParser.urlencoded({ extended: false }));
app.get('/', function (req, res) {
    const auth = req.header('Authorization')
    if (typeof auth === "undefined") {
        res.render('index', { username: "" });
        return
    }
    token = auth.replace('Bearer ', '')
    try {
        const decoded = jwt.verify(token, SECRET)
        if (decoded.user == "user") {
            res.render('index', {
                username: decoded.user
            });
        }
    } catch {
        res.status(401).render('index', {});
    }
})

app.post('/login', function (req, res) {
    if (req.body.username === "user" && req.body.password === "pass") {
        const token = jwt.sign({ user: req.body.username }, SECRET, { expiresIn: '1 day' })
        res.json({
            token
        });
    } else {
        res.redirect('/');
    }
})

app.get('/content', function (req, res) {
    const auth = req.header('Authorization')
    if (typeof auth === "undefined") {
        res.status(401).send({ error: 'Please authenticate.' })
        console.log(48)
        return
    }
    token = auth.replace('Bearer ', '')
    try {
        const decoded = jwt.verify(token, SECRET)
        if (decoded.user == "user") {
            res.status(200).send({ data: 'Welcome!' })
        }
    } catch {
        res.status(401).send({ error: 'Please authenticate.' })
    }
})

app.listen(8080);

這個路由中註冊了兩組 URL,一組用於處理登入,一組用於處理內容獲取。它對應到一個情境:使用者想要登入頁面,他會輸入帳號密碼,這組帳密經 Server 確認無誤後,會簽發 JWT 給 Client,Client 將會拿 JWT 來請求網站內容。

前面先插入 express 的 Middleware,用於處理靜態資源跟 Parse 訊息

app.use(express.static(__dirname + '/public'));
app.use(bodyParser.json({ extended: false }));
app.use(bodyParser.urlencoded({ extended: false }));

後面註冊路由,負責登入的路由是

app.post('/login', function (req, res) {
    if (req.body.username === "user" && req.body.password === "pass") {
        const token = jwt.sign({ user: req.body.username }, SECRET, { expiresIn: '1 day' })
        res.json({
            token
        });
    } else {
        res.redirect('/');
    }
})

假定 user/pass 是正確的帳密,當確認請求正確,使用 jwt.sign 跟 SECRET 來簽名,SECRET 可以是全域的任意值,這裡是 secret 。 expiresIn 用於註明該 JWT 的有效期限是 1 天。

簽名後回覆 token;否則回覆 Fail,告知登入失敗。

註冊獲取內容的路由

app.get('/content', function (req, res) {
    const auth = req.header('Authorization')
    if (typeof auth === "undefined") {
        res.status(401).send({ error: 'Please authenticate.' })
        return
    }
    token = auth.replace('Bearer ', '')
    try {
        const decoded = jwt.verify(token, SECRET)
        if (decoded.user == "user") {
            res.status(200).send({ data: 'Welcome!' })
        }
    } catch {
        res.status(401).send({ error: 'Please authenticate.' })
    }
})

由 HTTP 的 Header 中提取 Authorization 的訊息,JWT 會放在該處。拿到後,用 jwt.verify 進行驗證,如果解碼出來的 user 是 user ,返回內容給 Client,否則回覆認證失敗。

最後監聽 8080 Port,提供服務

app.listen(8080);

Implement Client Side

前端的部分分為 HTML、JavaScript 跟 CSS 三塊,HTML 可以用模板引擎渲染後,傳送給瀏覽器。

先來建立 HTML 的模板 views/index.ejs

<!DOCTYPE html>
<html>

<head>
    <link rel="stylesheet" type="text/css" href="./css/main.css">
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
        integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
    <script>
        async function login(username, password) {
            try {
                let res = await axios.post('http://127.0.0.1:8080/login', {
                    username: username,
                    password: password,
                })
                res = await axios.get('http://127.0.0.1:8080/content', {
                    headers: {
                        'Authorization': `Bearer ${res.data.token}`
                    },
                })
                let element = document.querySelector('body');
                element.innerHTML = `<h1 class="welcome">${res.data.data}</h1>`
            } catch (e) {
                console.log(e)
            }
        }
    </script>
</head>

<body class="text-center">
    <form method="post" action="/login" class="form-signin">
        <h1 class="h3 mb-3 font-weight-normal">Please sign in</h1>
        <label for="username" class="sr-only">Username</label>
        <input name="username" type="username" class="form-control" id="username" placeholder="Username">
        <label for="password" class="sr-only">Password</label>
        <input name="password" type="password" class="form-control" id="password" placeholder="Password">
        <div style="height:10px"></div>
        <button type="button" class="btn btn-lg btn-primary btn-block"
            onclick="login(this.form.username.value, this.form.password.value)">Login</button>
    </form>
</body>

</html>

前面的 head 處載入 JavaScript 跟 CSS,第一項是自定義的 CSS,這邊不細講,有興趣可以翻 GitHub,二三項是 Bootstrap 跟 axios,可以用 CDN 一併載入

<link rel="stylesheet" type="text/css" href="./css/main.css">
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
        integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">

因為程式很小,沒必要拆 JavaScript,將 JavaScript 的程式寫在 head,等等再回來看

<script>
    async function login(username, password) {
    ...
</script>

來看頁面主體,架構是

<body class="text-center">
    <form method="post" action="/login" class="form-signin">
        <h1 class="h3 mb-3 font-weight-normal">Please sign in</h1>
        <label for="username" class="sr-only">Username</label>
        <input name="username" type="username" class="form-control" id="username" placeholder="Username">
        <label for="password" class="sr-only">Password</label>
        <input name="password" type="password" class="form-control" id="password" placeholder="Password">
        <div style="height:10px"></div>
        <button type="button" class="btn btn-lg btn-primary btn-block"
            onclick="login(this.form.username.value, this.form.password.value)">Login</button>
    </form>
</body>

登入頁面上會有一張表單,表單上有兩個 input,可以讓使用者輸入帳號密碼。表單下方會有個 button,當使用者點擊 button 後,會觸發 login 這個函式,函式的參數為兩個 input 的值。

知道頁面有哪些元素後,可以回來看 JavaScript 做了哪些事情

async function login(username, password) {
    try {
        let res = await axios.post('http://127.0.0.1:8080/login', {
            username: username,
            password: password,
        })
        res = await axios.get('http://127.0.0.1:8080/content', {
            headers: {
                'Authorization': `Bearer ${res.data.token}`
            },
        })
        let element = document.querySelector('body');
        element.innerHTML = `<h1 class="welcome">${res.data.data}</h1>`
    } catch (e) {
        console.log(e)
    }
}

當使用者案下 button 後,會觸發 login 函式。這邊使用 async/await 來處理非同步邏輯,當 login 被執行時,會向 Server 送出 Post,內容帶有帳號密碼

let res = await axios.post('http://127.0.0.1:8080/login', {
    username: username,
    password: password,
})

等到非同步處理完畢後,使用取得的 JWT,用 Get 向 Server 要求內容

res = await axios.get('http://192.168.99.83:8080/content', {
    headers: {
        'Authorization': `Bearer ${res.data.token}`
    },
})

拿到內容後,修改 body 的 innerHTML,把原先的元素換掉

let element = document.querySelector('body');
element.innerHTML = `<h1 class="welcome">${res.data.data}</h1>`

最後用 try 來處理請求失敗的情形,將它印到 console 上

} catch (e) {
    console.log(e)
}

Operation

設計完成後,來實際操作吧,用 nodemon 打開後端程式

ken@DESKTOP-2R08VK6:~/git/medium-example-nodejs/jwt$ nodemon index.js
[nodemon] 2.0.4
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node index.js`

在瀏覽器輸入網址,打開頁面

可以看到設計好的表單。輸入帳號密碼,點擊 Login,得到歡迎訊息

打開開發者工具,當送出 /content 的 RESTful API 時,會看到請求有帶後端回覆的 JWT

如果在 /login 輸入錯誤的帳密,則會看到失敗訊息

小結

JWT 的概念簡單,容易操作,是現在常用到的驗證方法。但只要持有 JWT 的人,Server 都會將它當成合法對象,容易造成一些資安風險。如果因為管理 Token 的需求,而替 Token 設定過期的時間,安全是安全了些,卻會增加使用者驗證的麻煩,是權限管理時需要權衡的部分。

另外 JWS 這項 JWT 實作,為保持訊息對第三方透明,只有驗證訊息的真實性,沒有對訊息加密,只要用 base64 decode 回去,就能取得 header 跟 payload 的資訊,因此敏感資訊記得不要放在 JWT 內喔,以防在在網路上被別人竊取。

Reference

Read more

從個人貢獻者到管理者:關於領導的反思

從個人貢獻者到管理者:關於領導的反思

某個下雪天,我拖著病體,組裝一套供使用者簡報之用的破爛系統,莎朗進來發現我在操控台前勉強支撐,她便離開了,幾分鐘後,她端著一鍋湯回來,為我倒了一杯,我的精神為之一振。我問她要做的管理工作那麼多,怎麼會有空做這種事,她向我展露她的招牌微笑,說:「湯姆,這就是管理。」 -《Peopleware: Productive Projects and Teams》 有次跟一名職涯顧問聊天。我提到:「我希望透過打造產品來替別人創造價值,如果有很棒的團隊,我相信自己能辦到。」她問:「團隊是必須的嗎?」我愣住了,隨口說:「因為打造產品需要很多不同的職能……還需要可持續性的運作,對,我想團隊是必要的。」事後回想,她的問題很有趣,現代社會好像把「團隊」和「領導」當成是成功的標配,人力市場也一堆團隊主管的職缺,這是一則現代神話嗎?還是某種工業革命時代的遺產? 身為個人貢獻者的管理者 不是說團隊不重要,只是在現代,你會用不同的角度審視完成目標需要的條件。你想想,如果你是個開發者,自己架網站、

By Ken Chen
CDN 的快取失效設計:內容平台場景

CDN 的快取失效設計:內容平台場景

Phil Karlton 有句名言:「計算機科學中只有兩件難事:快取失效和命名。」 想像你在管理網站,因為傳輸速度與伺服器效能問題,網站讀取速度很差,特別是當你的使用者來自地球另外一端,常常需要等待幾秒才能看到畫面,這讓他們的使用體驗大打折扣。身為一名重視使用體驗的開發者,你肯定知道該如何解決這問題,沒錯,答案就是 CDN(內容傳遞網路)。 CDN 可以看成是服務商在全球各地建置伺服器,當你的網站內容(例如圖片、CSS、JavaScript、影片等)流經這些伺服器時,它會保留一份複本(稱為快取),等到下次有人讀取同樣的內容,CDN 會拿出複本給使用者。因為全球各地都有 CDN 節點,美國的使用者可以由美國節點提供,日本的使用者可以由日本節點提供。這樣既加速網路傳遞效率,也降低來源伺服器的效能壓力,可謂一舉兩得。 當然這是有條件的。CDN 會使用網址來判斷快取是否是相同檔案,假設你的內容以圖片為主,通常來說,當你更換圖片,新舊兩張圖片會有不同網址,被當成兩個不同的檔案,新圖使用新快取,舊快取留著也沒差;但如果你的內容是文字,新舊版文字很可能有相同網址,

By Ken Chen
收拾行李搬家去:從 Medium 到 Ghost

收拾行李搬家去:從 Medium 到 Ghost

想搬家想很久,連身邊的朋友都搬完了,我還沒動工。 原因是我懶,我討厭麻煩,每次有人問我吃什麼,我都回答麥當勞。搬家是一件麻煩事,我已經有一份很讚的工作了,全副精神都放在工作上,偶爾才會想起來,反正家什麼時候都能搬,一點也不急,有什麼好急的呢對吧。這樣一拖,就拖到現在。 繼續用 Medium 不好嗎? 跟男女朋友分手一樣,通常被問到:「對方不好嗎?」得到回答是:「也沒有不好啦,只是……(以下開放填空)。」 從優點開始講吧!Medium 的編輯器很棒,它是 WYSIWYG(所見即所得)類型的編輯器,能讓創作者快速發佈內容,也因為它讓內容發佈更容易了,它開始吸引一批優秀的創作者,這批創作者持續創作內容,又吸引來更多讀者,更多讀者激勵創作者產出內容,內容又再吸引讀者……這形成一個增強迴圈。Medium 還能支援多人協作,拜它時尚簡約的風格所賜,科技公司會使用 Medium 來打造品牌形象,例如我前公司的 Tech Blog

By Ken Chen
OpenTelemetry 的可觀察性工程:以 Sentry 為例

OpenTelemetry 的可觀察性工程:以 Sentry 為例

點進 OpenTelemetry 的官方文件,它最先映入眼中的句子是「什麼是 OpenTelemetry」。例如,它是套可觀察性框架,用於檢測、蒐集與導出遙測數據;它是開源且供應商中立,能搭配其他的開源工具,像 Jaeger 或 Prometheus;它能將應用程式與系統儀表化,無關是用 Go 還是 .NET 開發,也無關部署在 AWS 還是 GCP 上。 但是身為一名開發者,當下我們想的是:「公司常開發一些沒人要用的功能,聽說 OpenTelemetry 可以提高可觀察性,也許我們應該放棄開發功能,轉頭建立更好的開發環境。」「AWS 常常要不到需要的數據,也許我們應該改用另一套工具,像是 OpenTelemetry,來解決這件事。」我們想像 OpenTelemetry 「應該」要能解決目前面臨到的一些問題,就像在技術的鏡像中尋找願望一樣。 如果已經有在用 Sentry,還需要導入 OpenTelemetry

By Ken Chen