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

Weekly Issue 第 6 期:Duolingo CEO 看 AI 與遊戲化

現在是 AI 時代,大家都在想怎麼讓自己的產品跟 AI 掛勾,但具體要怎麼做呢?背後的思考有哪些?Duolingo 給出他們自己的觀點。 例如,現在的產品是否只是 AI 套皮,你接收使用者的問題,套上自己的提詞後,拿去給 OpenAI,要它回答你?在現在百家爭鳴的情況下,選擇哪個模型會有差嗎?AI 能帶來新用戶與新營收嗎?等等。 另外本週也選了一篇少數派的文章,談 AI 對 RSS 的影響,對 RSS 未來方向有興趣的人不妨看看。 🗞️ 熱門新聞 Duolingo CEO Luis von Ahn wants you addicted to learning Duolingo CEO 專訪,相當紮實,推薦閱讀。 「對我們來說,

By Ken Chen

Weekly Issue 第 5 期:OpenAI 的企業文化

我一直都喜歡看科技公司的願景與文化,原因是,我想知道別人是如何看待自己的使命,又是用什麼方式打造它。願景通常在官網都會有,但想要知道文化,只能聽內部人講講了。 Palantir 前陣子因為它不同於矽谷的文化,而引起很多討論。受此影響,前 OpenAI 的員工在離職創辦公司後,也發文談論他所見到的 OpenAI。最讓我震撼的是,他們幾乎沒有資金困擾,想的都是如何打造出色的 AI 模型。 🗞️ 熱門新聞 Reflections on OpenAI 前員工談 OpenAI 的內部文化。 讀起來最大的感觸是,有些價值觀、觀點、實踐,只有在世界級的公司跟資源下,才有可能建立起來。讓每個團隊各自為政,看誰能端出最好的成果,這對新創(特別是沒拿創投)實在太奢侈了。 我相信這種經歷會變成是「可以帶著走的饗宴」,那種衝擊也是最寶貴的。 AI Open Source Productivity METR 前陣子發了一篇研究,說使用 AI

By Ken Chen

Weekly Issue 第 4 期:Canonical 的面試經驗

這星期看了比較多職涯相關的內容,最讓我驚訝的是 Canonical 的面試流程,當我分享這則新聞後,有更多朋友紛紛補充他們的面試經驗:需要經歷三個 Tier,每個 Tier 都有三關,而內容甚至還包括問人選「高中成績」與「大學生活」。 我很難想像一家做 Linux 發行版的公司,會如此草率對待人選,這讓我對他們家的產品有了很大的問號。 🗞️ 熱門新聞 My experience with Canonical's interview process 這是一篇 Canonical 的面試經歷(如果你不知道什麼是 Canonical,就是開發 Ubuntu 的公司)。 整個過程讓人非常驚訝,甚至還需要人選回答「高中成績」,而在面試中做筆記居然是扣分項。我看完後有股移除 Ubuntu 的衝動。真的太扯啦。 What happens when engineers work

By Ken Chen