如何驗證使用者身分:使用 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 第 10 期:AI 機器人正造成網站負擔

隨著 LLM 變成日常的一部分,它們也在改變原有的網路生態。Fastly 的報告顯示,AI 機器人每分鐘可對網站發起高達 39K 次請求,日後造訪網站的,可能大多是機器人,而不是真人。 🗞️ 熱門新聞 Fastly warns AI bots can hit sites 39K times per minute 繼上次 Codeberg 的新聞後,Fastly 出報告指出 AI 機器人正造成網站營運負擔。 大多觀點延續幾個月來的趨勢:「網站負載增長主要並非來自人類訪客,而是代表聊天機器人公司運作的自動爬蟲與抓取程式。 」值得注意的是,AI Fetcher 的數量也在增加中,我猜這多少暗示了用戶搜尋資料的行為正在變化。 Meta 占了所有 AI 流量的 52% 🙄 ,相對下 Anthropic 只佔 3.76%

By Ken Chen

Weekly Issue 第 9 期:Ghost 發布 6.0 版本

Ghost Release 新版了!距離上次大版號更新,已經過了 3 年多,這幾年來,創作者經濟變化得很快,Ghost 也嘗試讓創作者更容易經營自己的內容。 我會等 6.0 發布一陣子,穩定下來後才會更新。很期待他們下一步會是什麼。 🗞️ 熱門新聞 Ghost 6.0 Ghost Release 6.0。 兩個重量級更新:支援 ActivityPub,讓 Ghost 可以 Leverage 社群媒體分發渠道;以及內建 Analytics,支援流量分析。這剛好就是兩個我最想要的功能,Great Work。 常說經營內容的痛點在,不知道如何發佈內容,不知道訪客從哪來。當然這都可以用工具協助,例如設定 GA、或者使用 Postiz 等來經營社群,可是我覺得一個好的平台應該要替創作者處理掉這些事,Ghost

By Ken Chen

Weekly Issue 第 8 期:數位時代的遷徙自由

以前在開發內容平台產品時,常常想,如果有天我們的使用者要離開平台,他們擁有自由嗎?在現代,數位創作者有點像是佃農,替平台生產內容,可是因為數位落差,他們沒有移動的能力。 隨著時代進步,法規應該要與時俱進,這期選了數位部的公告草案,告訴我們科技與制度可以如何相輔相成。 另外,從本期開始,加入了目錄大綱,希望讓讀者閱讀時能更容易在不同議題間切換。 🗞️ 熱門新聞 社交資料可攜權與互通性 在唐鳳那看到這則消息,最近衛城出版編輯的帳號被無預警停權,引發討論,我自己也常常焦慮,當使用這些便利的平台服務時,我們是不是交出一些沒意識到的權利? 身為個人,可行的策略是,在發布內容到平台前,先保留一份在自己手中,但這其中的不平等顯而易見。《數位選擇法案》讓我理解到,創作者有機會在一個更好更平等的環境下創作。 我希望台灣也能有這樣的一天。 I gave the AI arms and legs – then it rejected me 在 HN 上看到的新聞,有名開發者發現自己的函式庫被用在 Claude

By Ken Chen

Weekly Issue 第 7 期:從 GitHub Spark 看 Prompt 工程

近期開始有人建議用 Context Engineering 來取代 Prompt Engineering,的確相較於 Prompt,Context 是更精確的用詞。前一期也提到,當 Duolingo 的 CEO 被問到 AI 是否只是模型套皮時,他也說模型一定有影響,但更多是關乎你的 Context。 那麼,業界現在是如何看待 Prompt 的呢?Github Spark 跟 V0 的例子或許能提供一些參考。 🗞️ 熱門新聞 Using GitHub Spark to reverse engineer GitHub Spark GitHub Spark 最近推出公開預覽,讓你可以用 prompt 直接開發應用。 作者用逆向工程,找出 Spark 的 system

By Ken Chen