在Next.js中設定身份驗證時的注意事項

在Next.js中設定身份驗證時的注意事項

在過去幾年中,為應用程式新增身份驗證功能已經從晦澀難懂的複雜功能變成了只需使用 API 即可實現的功能。

關於如何在 Next.js 中實現特定身份驗證方案的示例軟體倉庫和教程並不缺乏,但關於為什麼要選擇哪些方案、工具和權衡的內容卻較少。

本篇文章將介紹在 Next.js 中進行身份驗證時需要考慮的事項,從選擇提供商到構建登入路由,以及在伺服器端和客戶端之間做出選擇。

選擇身份驗證方法/提供商

基本上有 1000 種方法可以在應用程式中建立身份驗證。與其在這裡關注特定的提供商(這是另一篇博文的主題),不如讓我們來看看認證解決方案的型別以及每種解決方案的幾個示例。在實施方面,next-auth 正迅速成為將 Next.js 應用程式與多個提供商整合、新增 SSO 等功能的熱門選擇。

傳統資料庫

這種方法非常簡單:將使用者名稱和密碼儲存在關聯式資料庫中。當使用者首次註冊時,您會在 “users” 表中插入一行新記錄,其中包含所提供的資訊。當使用者登入時,將根據表中儲存的資訊檢查其證書。當使用者要更改密碼時,就需要更新表中的值。

縱觀現有的所有應用程式,傳統的資料庫驗證無疑是最流行的驗證方案,而且基本上一直存在。它非常靈活、便宜,而且不會將你鎖定在任何特定的供應商。但你確實需要自己構建它,尤其要擔心加密問題,並確保那些可愛的密碼不會落入壞人之手。

資料庫供應商提供的身份驗證解決方案

在過去幾年裡(Firebase 的功勞不只幾年前),託管資料庫提供商提供某種託管身份驗證解決方案已成為相對標準的做法。FirebaseSupabaseAWS 都通過一套 API 提供託管資料庫和託管身份驗證服務,這套 API 可以輕鬆抽象使用者建立和會話管理(稍後詳述)。

使用 Supabase 身份驗證登入使用者非常簡單

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
async function signInWithEmail() {
const { data, error } = await supabase.auth.signInWithPassword({
email: 'example@email.com',
password: 'example-password',
})
}
async function signInWithEmail() { const { data, error } = await supabase.auth.signInWithPassword({ email: 'example@email.com', password: 'example-password', }) }
async function signInWithEmail() {
const { data, error } = await supabase.auth.signInWithPassword({
email: 'example@email.com',
password: 'example-password',
})
}

非資料庫提供商提供的身份驗證解決方案

也許比 DBaaS 提供的身份驗證服務更常見的是整個公司或產品提供的身份驗證服務。Auth0 早在 2013 年就已出現(現在歸 Okta 所有),最近又增加了 Stytch 等產品,這些產品都優先考慮開發人員的體驗,並獲得了一定的市場份額。

Auth0 用於身份驗證

Auth0 用於身份驗證

單點登入

單點登入讓你可以將身份 “外包 “給外部提供商,外部提供商可以是像 Okta 這樣注重安全的企業級供應商,也可以是像 Google 或 GitHub 這樣更廣泛採用的供應商。Google SSO 在 SaaS 領域無處不在,而一些以開發人員為重點的工具只能通過 GitHub 進行身份驗證。

無論你選擇哪種供應商,SSO 通常都是上述其他型別身份驗證的附加元件,在與外部平臺整合方面有自己的特殊性(警告:SAML 使用 XML)。

好吧,哪一種適合我?

這裡沒有 “正確 “的選擇–什麼適合你的專案取決於你的優先事項。如果您希望快速開展工作,而不需要大量的前期配置,那麼將認證外包(甚至將包括使用者介面在內的全部工作外包給 Auth0 這樣的公司)是合理的。如果您預計會有更復雜的設定,那麼建立自己的驗證後端是合理的。如果你希望支援更大的客戶,就需要在某些時候新增 SSO。

Next.js 目前非常流行,大多數身份驗證提供商都有 Next.js 專用文件和整合指南。

構建註冊和登入路徑,以及進行額外身份驗證的技巧

一些身份驗證提供商(如 Auth0)實際上提供了用於註冊和登入的整個託管網頁。但是,如果你要從頭開始建立這些頁面,我發現在建立過程中儘早建立這些頁面是非常有用的,因為當你真正實施身份驗證時,你需要這些頁面作為重定向。

因此,先建立這些頁面的結構,然後再將請求新增到後臺,這樣做才有意義。實現身份驗證的最直接方法就是擁有兩個這樣的路由:

  • 一個用於註冊
  • 另一個用於使用者已有賬戶後的登入

除了基本功能外,您還需要處理邊緣情況,例如使用者忘記密碼時。有些團隊喜歡在單獨的路徑上設定密碼重置流程,而有些團隊則喜歡在常規登入頁面上新增動態使用者介面元素。

一個漂亮的註冊頁面可能並不意味著成功與失敗的區別,但一些小細節卻能給人留下好印象,並在整體上提供更好的使用者體驗。以下是從網路上一些網站整理出來的,它們在認證流程中加入了一些額外的愛。

1. 如果有活動會話,更新導航欄

Stripe 導航欄中的行動號召會根據您是否有認證會話而改變。下面是未通過身份驗證時營銷網站的樣子。請注意登入的操作提示:

Stripe 主頁會根據您是否通過身份驗證來更改 CTA

Stripe 主頁會根據您是否通過身份驗證來更改 CTA

下面是通過身份驗證後的頁面。請注意,行動呼籲會將使用者帶到儀表板,而不是登入:

Stripe 主頁更改

Stripe 主頁更改

雖然沒有從根本上改變我的 Stripe 體驗,但還是不錯的。

一個有趣的技術問題:大多數公司都不會讓營銷網站的導航欄 “依賴” 身份驗證,這是有原因的,因為這意味著每次頁面載入時都要額外請求 API 檢查身份驗證狀態,而大多數頁面的訪問者可能都沒有通過身份驗證。

2. 在登錄檔單旁新增一些有用的內容

在過去幾年中,尤其是在 SaaS 領域,公司開始在註冊頁面新增內容,以 “鼓勵” 使用者完成註冊。這有助於提高頁面的轉化率,至少可以逐步提高。

下面是 Retool 的一個註冊頁面,邊上有一個動畫和一些標識:

在登錄檔單旁新增一些有用的內容

如果要這樣做,請確保兩側的字型相匹配。

這些額外的小內容可以幫助提醒使用者註冊的目的以及為什麼需要註冊。

3. 如果使用密碼:建議或強制使用強密碼

我可以肯定地說,密碼本身就不安全,這是開發人員的常識,但這並不是所有註冊你產品的人的常識。鼓勵使用者建立安全密碼對你和他們都有好處。

Coinbase 對註冊要求非常嚴格,要求你使用比名字更復雜的安全密碼:

 

Coinbase 上的弱密碼

Coinbase 上的弱密碼

用密碼管理器生成一個密碼後,我就可以使用了:

 

Coinbase 上的強密碼

Coinbase 上的強密碼

不過,使用者介面並沒有告訴我密碼不夠安全的原因,也沒有告訴我除了數字之外的其他要求。在產品文案中加入這些要求,會讓使用者使用起來更順暢,也有助於避免重試密碼時的挫敗感。

4. 為輸入內容貼標籤,讓它們與密碼管理器

配合默契每三個美國人中就有一個使用密碼管理器(如 1Password),但網上仍有許多表單忽略了 HTML 輸入中的 `type=` 字樣。讓您的表單與密碼管理器配合默契

  • 將輸入元素包含在表單元素中
  • 為輸入賦予型別和標籤
  • 為輸入新增自動完成功能
  • 不要動態新增欄位(我看著你呢,Delta

這可以讓您在 10 秒鐘內順利完成登入,也可以讓您手動完成登入,尤其是在移動裝置上。

在會話和 JWT 之間做出選擇

使用者通過身份驗證後,您需要選擇一種策略來在後續請求中保持該狀態。HTTP 是無狀態的,我們當然不想在每次請求時都要求使用者提供密碼。目前有兩種流行的處理方法會話(或 Cookie)和 JWTs(JSON 網路令牌),它們的區別在於由伺服器還是客戶端來完成這項工作。

會話,又名 Cookie

在基於會話的身份驗證中,維護身份驗證的邏輯和工作由伺服器處理。基本流程如下:

  1. 使用者通過登入頁面進行身份驗證。
  2. 伺服器建立一個記錄,代表這個特定的瀏覽 “會話”。該記錄通常會被插入資料庫,其中包含一個隨機識別符號和有關會話的詳細資訊,如開始時間和過期時間。
  3. 該隨機識別符號(如 “6982e583b1874abf9078e1d1dd5442f1″)會被髮送到瀏覽器,並作為 Cookie 儲存
  4. 在客戶端的後續請求中,會包含該識別符號,並與資料庫中的會話表進行核對

在會話持續多長時間、何時撤銷會話等方面,這種方法非常簡單,而且可以調整。其缺點是,由於要對資料庫進行大量寫入和讀取,因此會有很大的延遲,但這對大多數讀者來說可能不是主要的考慮因素。

JSON 網路令牌(JWT)

JWTs 不需要在伺服器上處理後續請求的身份驗證,而是可以在客戶端處理(大部分)。工作原理如下:

  1. 使用者通過登入頁面進行身份驗證。
  2. 伺服器會生成一個 JWT,其中包含使用者身份、授予的許可權和到期日期(以及其他潛在的資訊)。
  3. 伺服器會對令牌內容進行加密簽名,然後將整個內容傳送給客戶端。
  4. 對於每個請求,客戶端都可以解密令牌,並驗證使用者是否有許可權提出請求(所有這些都無需與伺服器通訊)。

將初始驗證後的所有工作都解除安裝到客戶端後,應用程式的載入和執行速度都會大大加快。但有一個主要問題:伺服器無法使 JWT 失效。如果使用者想登出裝置或其授權範圍發生變化,就需要等到 JWT 失效。

在伺服器端和客戶端 Auth 之間做出選擇

Next.js 的部分優勢在於內建的靜態渲染功能–如果你的頁面是靜態的,即不需要呼叫任何外部 API,Next.js 就會自動對其進行快取,並通過 CDN 以極快的速度提供服務。如果檔案中不包含任何 `getServerSideProps` 或 `getInitialProps` 內容,Next.js 13 之前的版本就會知道頁面是否是靜態的,而 Next.js 13 之後的版本則會使用 React Server Components 來實現這一功能。

對於身份驗證,你可以選擇:渲染一個靜態的 “載入” 頁面並在客戶端執行獲取操作,或者在伺服器端執行所有操作。對於需要身份驗證的頁面,你可以呈現一個靜態的 “骨架”,然後在客戶端發出身份驗證請求。理論上,這意味著即使初始內容尚未完全就緒,頁面載入速度也會更快。

下面是文件中的一個簡化示例,只要使用者物件尚未就緒,就會呈現載入狀態:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
import useUser from '../lib/useUser'
const Profile = () => {
// Fetch the user client-side
const { user } = useUser({ redirectTo: '/login' })
// Server-render loading state
if (!user || user.isLoggedIn === false) {
// Build some sort of loading page here
return <div>Loading...</div>
}
// Once the user request finishes, show the user
return (
<div>
<h1>Your Account</h1>
<p>Username: {JSON.stringify(user.username,null)}</p>
<p>Email: {JSON.stringify(user.email,null)}</p>
<p>Address: {JSON.stringify(user.address,null)}</p>
</div>
)
}
export default Profile
import useUser from '../lib/useUser' const Profile = () => { // Fetch the user client-side const { user } = useUser({ redirectTo: '/login' }) // Server-render loading state if (!user || user.isLoggedIn === false) { // Build some sort of loading page here return <div>Loading...</div> } // Once the user request finishes, show the user return ( <div> <h1>Your Account</h1> <p>Username: {JSON.stringify(user.username,null)}</p> <p>Email: {JSON.stringify(user.email,null)}</p> <p>Address: {JSON.stringify(user.address,null)}</p> </div> ) } export default Profile
import useUser from '../lib/useUser'
const Profile = () => {
// Fetch the user client-side
const { user } = useUser({ redirectTo: '/login' })
// Server-render loading state
if (!user || user.isLoggedIn === false) {
// Build some sort of loading page here
return <div>Loading...</div>
}
// Once the user request finishes, show the user
return (
<div>
<h1>Your Account</h1>
<p>Username: {JSON.stringify(user.username,null)}</p>
<p>Email: {JSON.stringify(user.email,null)}</p>
<p>Address: {JSON.stringify(user.address,null)}</p>
</div>
)
}
export default Profile

請注意,您需要在此構建某種載入 UI,以便在客戶端提出載入後請求時保留空間。

如果想簡化操作並在伺服器端執行身份驗證,可以將身份驗證請求新增到 `getServerSideProps` 函式中,Next 會等待請求完成後再渲染頁面。與上面程式碼段中的條件邏輯不同,你可以執行一些更簡單的東西,比如 Next 文件中的簡化版本:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
import withSession from '../lib/session'
export const getServerSideProps = withSession(async function ({ req, res }) {
const { user } = req.session
if (!user) {
return {
redirect: {
destination: '/login',
permanent: false,
},
}
}
return {
props: { user },
}
})
const Profile = ({ user }) => {
// Show the user. No loading state is required
return (
<div>
<h1>Your Account</h1>
<p>Username: {JSON.stringify(user.username,null)}</p>
<p>Email: {JSON.stringify(user.email,null)}</p>
<p>Address: {JSON.stringify(user.address,null)}</p>
</div>
)
}
export default Profile
import withSession from '../lib/session' export const getServerSideProps = withSession(async function ({ req, res }) { const { user } = req.session if (!user) { return { redirect: { destination: '/login', permanent: false, }, } } return { props: { user }, } }) const Profile = ({ user }) => { // Show the user. No loading state is required return ( <div> <h1>Your Account</h1> <p>Username: {JSON.stringify(user.username,null)}</p> <p>Email: {JSON.stringify(user.email,null)}</p> <p>Address: {JSON.stringify(user.address,null)}</p> </div> ) } export default Profile
import withSession from '../lib/session'
export const getServerSideProps = withSession(async function ({ req, res }) {
const { user } = req.session
if (!user) {
return {
redirect: {
destination: '/login',
permanent: false,
},
}
}
return {
props: { user },
}
})
const Profile = ({ user }) => {
// Show the user. No loading state is required
return (
<div>
<h1>Your Account</h1>
<p>Username: {JSON.stringify(user.username,null)}</p>
<p>Email: {JSON.stringify(user.email,null)}</p>
<p>Address: {JSON.stringify(user.address,null)}</p>
</div>
)
}
export default Profile

這裡仍然有處理身份驗證失敗的邏輯,但會重定向到登入,而不是呈現載入狀態。

小結

那麼,哪種方案適合你的專案呢?首先要評估你對身份驗證方案的速度有多大信心。如果您的請求完全不耗費時間,您可以在伺服器端執行這些請求,並避免載入狀態。如果你想優先立即呈現某些內容,然後等待請求,那就跳過 `getServerSideProps` 並在其他地方執行身份驗證。

在使用 Next 時,這是不對每個頁面一概要求身份驗證的一個很好的理由。這樣做更簡單,但意味著你首先會錯過網路框架的效能優勢。

評論留言