如何驗證使用者身分:使用 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

Weekly Issue 第 17 期:n8n 在 C 輪募得 180m

現在新創企業已經離不開 AI 了。像 n8n 這樣的自動化工具,重新用 AI 話題包裝後,可以在自由市場上募得鉅款;Postman 也需要在它的口號中,強調對 AI 的重要性。 我相信 AI 能幫助人們,當我到一個新國家,對任何事情都不熟時,AI 給了我很多幫助。但市場的話題像一場無差別的風暴,每個公司都面對一支麥克風,麥克風傳出的經 AI 編輯過的聲音。 🗞️ 熱門新聞 n8n raises $180m to get AI closer to value with orchestration n8n C 輪募了 180M 美元,沒想到它可以這麼值錢。 基於 zapier 只有 5B 的估值,

By Ken Chen

Weekly Issue 第 16 期:Anduril 的 MVP

近期嘗試降低 AI 相關選文,主要是因為我在閱讀時,不容易判斷內容是正確還是錯誤。本次選的「AI Evals 大辯論」在這點上就做得很棒,正反意見並陳,讓讀者知道自己哪些論點也有人支持,哪些論點具有爭議。 🗞️ 熱門新聞 The Amusement Park for Engineers 原本看是 Anduril 嘀咕幾聲(我對國防工業沒興趣),但看到一半覺得太讚了,推薦所有做產品的人閱讀。 這句話開始點亮我的眼睛:「那座臨時搭建的塔,是我們自掏腰包、為了驗證可行性而做的,幫助攔截了近一千磅的大麻,並導致數十起毒品走私逮捕 」 業界都說要做 MVP,但到底什麼是 viable?沒有 viable 的 MVP 只能稱為 prototype 而已。合作的 PM 有次說的傳神:「別人要樣品屋,但我們只有沒屋頂的牆壁。」 這篇雖然沒有講到 agile,卻做到

By Ken Chen

Weekly Issue 第 15 期:Go 語言從 1.25 支援 Flight Recorder

最近安排旅遊計畫,會到 Brisbane 居住三個月,突然跟熟悉的環境分開,用陌生眼光看待周圍一切,真是個特別的體驗。 世界依然在轉動,只是用了不同速度,反映在每週週報上,是項目變少了,可是內容變長了。 🗞️ 熱門新聞 Flight Recorder in Go 1.25 Go 1.25 開始支援 Flight Recorder。 以前要抓 trace,都是要等到事發後才能抓,有沒有可能事發前抓呢?有,原理很簡單,配置一塊記憶體存放臨時的 trace,如果符合條件,輸出持久化,否則丟棄,這就是 Flight Recorder。 官方給的範例很讚,像 slow request 這類例子,常常是處理 request 時遇到問題,在沒有 Flight Recorder

By Ken Chen

Weekly Issue 第 14 期:Product Hunt 已死

近期台灣技術圈新聞應該要算 Vibe Coding,無獨有偶的是,HN 上也談到類似的議題:行銷跟技術的界線在哪?哪些東西可以包裝成商品販售,哪些則是應該要有基本的自我審核? 🗞️ 熱門新聞 Product Hunt is Dead 有位產品創辦人寫了篇 Blog,標題比較聳動:Product Hunt 已死。 我沒特別 Follow Product Hunt,只有偶爾看看,但看完還是五味雜陳。依照文中所說,當你在 Product Hunt 發布產品後,會有一堆人來跟你說需不需要買讚,這讓我想到以前書市買榜的例子。 有人可能會覺得這是行銷,只是對我來說,行銷跟欺騙還是兩回事。我比較不確定的是,假設今天換個場景,改為無傷大雅的善意謊言,我會同意嗎? ✨ 科技觀點 How to Lead in a Room Full of Experts

By Ken Chen