為你的 Next.js (App Router) 應用程式新增驗證 (Authentication)
- 範例專案可在我們的 SDK repository 中找到。
- 此範例基於 Next.js 的 App Router。
- 教學影片可在我們的 YouTube 頻道 上觀看。
先決條件
- 一個 Logto Cloud 帳戶或 自託管 Logto。
- 一個已創建的 Logto 傳統應用程式。
安裝
透過你喜愛的套件管理器安裝 Logto SDK:
- npm
- pnpm
- yarn
npm i @logto/next
pnpm add @logto/next
yarn add @logto/next
整合
準備配置
為 Logto 客戶端準備配置:
import { LogtoNextConfig } from '@logto/next';
export const logtoConfig: LogtoNextConfig = {
appId: '<your-application-id>',
appSecret: '<your-app-secret-copied-from-console>',
endpoint: '<your-logto-endpoint>', // 例如 http://localhost:3001
baseUrl: '<your-nextjs-app-base-url>', // 例如 http://localhost:3000
cookieSecret: 'complex_password_at_least_32_characters_long',
cookieSecure: process.env.NODE_ENV === 'production',
};
配置重定向 URI
在深入細節之前,以下是終端使用者體驗的快速概覽。登入流程可簡化如下:
- 你的應用程式呼叫登入方法。
- 使用者被重定向至 Logto 登入頁面。對於原生應用程式,系統瀏覽器會被開啟。
- 使用者登入後被重定向回你的應用程式(配置為重定向 URI)。
關於基於重導的登入
- 此驗證流程遵循 OpenID Connect (OIDC) 協議,Logto 強制執行嚴格的安全措施以保護使用者登入。
- 如果你有多個應用程式,可以使用相同的身分提供者 (IdP, Identity provider)(Logto)。一旦使用者登入其中一個應用程式,Logto 將在使用者訪問另一個應用程式時自動完成登入流程。
欲了解更多關於基於重導登入的原理和優勢,請參閱 Logto 登入體驗解析。
在以下的程式碼片段中,我們假設你的應用程式運行在 http://localhost:3000/
。
配置重定向 URI
切換到 Logto Console 的應用程式詳細資訊頁面。新增一個重定向 URI http://localhost:3000/callback
。
就像登入一樣,使用者應被重定向到 Logto 以登出共享會話。完成後,將使用者重定向回你的網站會很不錯。例如,將 http://localhost:3000/
新增為登出後重定向 URI 區段。
然後點擊「儲存」以保存更改。
處理回調
使用者登入後,Logto 會將使用者重定向回上面配置的重定向 URI。然而,仍需進行一些操作以確保應用程式正常運作。
我們提供了一個輔助函數 handleSignIn
來處理登入回調:
import { handleSignIn } from '@logto/next/server-actions';
import { redirect } from 'next/navigation';
import { NextRequest } from 'next/server';
import { logtoConfig } from '../logto';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
await handleSignIn(logtoConfig, searchParams);
redirect('/');
}
實作登入與登出
實作登入與登出按鈕
在 Next.js App Router 中,事件在客戶端元件中處理,因此我們需要先建立兩個元件:SignIn
和 SignOut
。
'use client';
type Props = {
onSignIn: () => Promise<void>;
};
const SignIn = ({ onSignIn }: Props) => {
return (
<button
onClick={() => {
onSignIn();
}}
>
Sign In
</button>
);
};
export default SignIn;
'use client';
type Props = {
onSignOut: () => Promise<void>;
};
const SignOut = ({ onSignOut }: Props) => {
return (
<button
onClick={() => {
onSignOut();
}}
>
Sign Out
</button>
);
};
export default SignOut;
記得在檔案頂部添加 'use client'
以指示這些元件是客戶端元件。
將按鈕添加到首頁
在客戶端元件中不允許定義內嵌的 "use server" 註解的伺服器操作。我們必須透過從伺服器元件傳遞屬性來實現。
現在讓我們在首頁添加登入和登出按鈕。我們需要在需要時調用 SDK 中的伺服器操作。為此,使用 getLogtoContext
來獲取驗證狀態。
import { getLogtoContext, signIn, signOut } from '@logto/next/server-actions';
import SignIn from './sign-in';
import SignOut from './sign-out';
import { logtoConfig } from './logto';
const Home = () => {
const { isAuthenticated, claims } = await getLogtoContext(logtoConfig);
return (
<nav>
{isAuthenticated ? (
<p>
Hello, {claims?.sub},
<SignOut
onSignOut={async () => {
'use server';
await signOut(logtoConfig);
}}
/>
</p>
) : (
<p>
<SignIn
onSignIn={async () => {
'use server';
await signIn(logtoConfig);
}}
/>
</p>
)}
</nav>
);
};
export default Home;
檢查點:測試你的應用程式
現在,你可以測試你的應用程式:
- 執行你的應用程式,你會看到登入按鈕。
- 點擊登入按鈕,SDK 會初始化登入流程並將你重定向到 Logto 登入頁面。
- 登入後,你將被重定向回應用程式並看到登出按鈕。
- 點擊登出按鈕以清除權杖存儲並登出。
獲取使用者資訊
顯示使用者資訊
當使用者登入後,getLogtoContext()
的返回值將是一個包含使用者資訊的物件。你可以在應用程式中顯示這些資訊:
import { getLogtoContext } from '@logto/next/server-actions';
import { logtoConfig } from './logto';
export default async function Home() {
const { claims } = await getLogtoContext(logtoConfig);
return (
<main>
{claims && (
<div>
<h2>宣告 (Claims):</h2>
<table>
<thead>
<tr>
<th>名稱</th>
<th>值</th>
</tr>
</thead>
<tbody>
{Object.entries(claims).map(([key, value]) => (
<tr key={key}>
<td>{key}</td>
<td>{String(value)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</main>
);
}
在 API 路由處理器中獲取使用者資訊
你也可以在 API 路由處理器中獲取使用者資訊:
import { getLogtoContext } from '@logto/next/server-actions';
import { logtoConfig } from '../../logto';
export const dynamic = 'force-dynamic';
export async function GET() {
const { claims } = await getLogtoContext(logtoConfig);
return Response.json({ claims });
}
請求額外的宣告 (Claims)
你可能會發現從 getLogtoContext
返回的物件中缺少一些使用者資訊。這是因為 OAuth 2.0 和 OpenID Connect (OIDC) 的設計遵循最小權限原則 (PoLP, Principle of Least Privilege),而 Logto 是基於這些標準構建的。
預設情況下,僅返回有限的宣告 (Claims)。如果你需要更多資訊,可以請求額外的權限範圍 (Scopes) 以存取更多宣告。
「宣告 (Claim)」是對主體所做的斷言;「權限範圍 (Scope)」是一組宣告。在目前的情況下,宣告是關於使用者的一部分資訊。
以下是權限範圍與宣告關係的非規範性範例:
「sub」宣告表示「主體 (Subject)」,即使用者的唯一識別符(例如使用者 ID)。
Logto SDK 將始終請求三個權限範圍:openid
、profile
和 offline_access
。
若要請求額外的權限範圍 (Scopes),可以在初始化 Logto 客戶端時配置參數:
import { UserScope, LogtoNextConfig } from '@logto/next';
export const logtoConfig: LogtoNextConfig = {
scopes: [UserScope.Email, UserScope.Phone], // 如有需要可新增更多權限範圍
// ...其他配置
});
然後你可以在上下文響應中訪問額外的宣告 (Claims):
export default async function Home() {
const { claims: { email } = {}, } = await getLogtoContext(logtoConfig);
return (
<div>
{email && <p>電子郵件: {email}</p>}
</div>
);
};
export default Home;
需要網路請求的宣告 (Claims)
為了防止 ID 權杖 (ID token) 膨脹,某些宣告 (Claims) 需要透過網路請求來獲取。例如,即使在權限範圍 (Scopes) 中請求了 custom_data
宣告,它也不會包含在使用者物件中。要存取這些宣告,你可以配置 fetchUserInfo
選項:
export default async function Home() {
const { userInfo } = await getLogtoContext(logtoConfig, { fetchUserInfo: true });
return (
<div>
{userInfo && <p>電子郵件: {userInfo.email}</p>}
</div>
);
};
export default Home;
fetchUserInfo
,SDK 將在使用者登入後透過請求 userinfo 端點 來獲取使用者資訊,並且一旦請求完成,userInfo
將可用。
權限範圍 (Scopes) 和宣告 (Claims)
Logto 使用 OIDC 權限範圍 (Scopes) 和宣告 (Claims) 慣例 來定義從 ID 權杖 (ID token) 和 OIDC 使用者資訊端點 (userinfo endpoint) 獲取使用者資訊的權限範圍和宣告。無論是「權限範圍 (Scope)」還是「宣告 (Claim)」,都是 OAuth 2.0 和 OpenID Connect (OIDC) 規範中的術語。
以下是支援的權限範圍 (Scopes) 及其對應的宣告 (Claims):
openid
宣告名稱 | 類型 | 描述 | 需要使用者資訊嗎? |
---|---|---|---|
sub | string | 使用者的唯一識別符 | 否 |
profile
宣告名稱 | 類型 | 描述 | 需要使用者資訊嗎? |
---|---|---|---|
name | string | 使用者的全名 | 否 |
username | string | 使用者的用戶名 | 否 |
picture | string | 使用者個人資料圖片的 URL。此 URL 必須指向圖像文件(例如 PNG、JPEG 或 GIF 圖像文件),而不是包含圖像的網頁。請注意,此 URL 應特別參考適合在描述使用者時顯示的個人資料照片,而不是使用者拍攝的任意照片。 | 否 |
created_at | number | 使用者創建的時間。時間以自 Unix epoch(1970-01-01T00:00:00Z)以來的毫秒數表示。 | 否 |
updated_at | number | 使用者資訊最後更新的時間。時間以自 Unix epoch(1970-01-01T00:00:00Z)以來的毫秒數表示。 | 否 |
其他 標準宣告 包括 family_name
、given_name
、middle_name
、nickname
、preferred_username
、profile
、website
、gender
、birthdate
、zoneinfo
和 locale
也將包含在 profile
權限範圍中,無需請求使用者資訊端點。與上述宣告的不同之處在於,這些宣告僅在其值不為空時返回,而上述宣告在值為空時將返回 null
。
與標準宣告不同,created_at
和 updated_at
宣告使用毫秒而非秒。
email
宣告名稱 | 類型 | 描述 | 需要使用者資訊嗎? |
---|---|---|---|
string | 使用者的電子郵件地址 | 否 | |
email_verified | boolean | 電子郵件地址是否已驗證 | 否 |
phone
宣告名稱 | 類型 | 描述 | 需要使用者資訊嗎? |
---|---|---|---|
phone_number | string | 使用者的電話號碼 | 否 |
phone_number_verified | boolean | 電話號碼是否已驗證 | 否 |
address
請參閱 OpenID Connect Core 1.0 以獲取地址宣告的詳細資訊。
custom_data
宣告名稱 | 類型 | 描述 | 需要使用者資訊嗎? |
---|---|---|---|
custom_data | object | 使用者的自訂資料 | 是 |
identities
宣告名稱 | 類型 | 描述 | 需要使用者資訊嗎? |
---|---|---|---|
identities | object | 使用者的連結身分 | 是 |
sso_identities | array | 使用者的連結 SSO 身分 | 是 |
roles
宣告名稱 | 類型 | 描述 | 需要使用者資訊嗎? |
---|---|---|---|
roles | string[] | 使用者的角色 | 否 |
urn:logto:scope:organizations
宣告名稱 | 類型 | 描述 | 需要使用者資訊嗎? |
---|---|---|---|
organizations | string[] | 使用者所屬的組織 ID | 否 |
organization_data | object[] | 使用者所屬的組織資料 | 是 |
urn:logto:scope:organization_roles
宣告名稱 | 類型 | 描述 | 需要使用者資訊嗎? |
---|---|---|---|
organization_roles | string[] | 使用者所屬的組織角色,格式為 <organization_id>:<role_name> | 否 |
考慮到效能和資料大小,如果「需要使用者資訊嗎?」為「是」,則表示該宣告不會顯示在 ID 權杖中,而會在 使用者資訊端點 回應中返回。
API 資源
我們建議先閱讀 🔐 角色型存取控制 (RBAC, Role-Based Access Control),以瞭解 Logto RBAC 的基本概念以及如何正確設定 API 資源。
配置 Logto 客戶端
一旦你設定了 API 資源,就可以在應用程式中配置 Logto 時新增它們:
export const logtoConfig = {
// ...other configs
resources: ['https://shopping.your-app.com/api', 'https://store.your-app.com/api'], // 新增 API 資源 (API resources)
};
每個 API 資源都有其自身的權限(權限範圍)。
例如,https://shopping.your-app.com/api
資源具有 shopping:read
和 shopping:write
權限,而 https://store.your-app.com/api
資源具有 store:read
和 store:write
權限。
要請求這些權限,你可以在應用程式中配置 Logto 時新增它們:
export const logtoConfig = {
// ...other configs
scopes: ['shopping:read', 'shopping:write', 'store:read', 'store:write'],
resources: ['https://shopping.your-app.com/api', 'https://store.your-app.com/api'],
};
你可能會注意到權限範圍是獨立於 API 資源定義的。這是因為 OAuth 2.0 的資源標示符 (Resource Indicators) 指定請求的最終權限範圍將是所有目標服務中所有權限範圍的笛卡兒積。
因此,在上述情況中,權限範圍可以從 Logto 的定義中簡化,兩個 API 資源都可以擁有 read
和 write
權限範圍而不需要前綴。然後,在 Logto 配置中:
export const logtoConfig = {
// ...other configs
scopes: ['read', 'write'],
resources: ['https://shopping.your-app.com/api', 'https://store.your-app.com/api'],
};
對於每個 API 資源,它將請求 read
和 write
權限範圍。
請求未在 API 資源中定義的權限範圍是可以的。例如,即使 API 資源中沒有可用的 email
權限範圍,你也可以請求 email
權限範圍。不可用的權限範圍將被安全地忽略。
成功登入後,Logto 將根據使用者的角色向 API 資源發出適當的權限範圍。
獲取 API 資源的存取權杖
要獲取特定 API 資源的存取權杖 (Access token),你可以使用 getAccessToken
方法:
在客戶端元件中不允許定義內嵌的 "use server" 註解的伺服器操作。我們必須透過從伺服器元件傳遞屬性來實現。
import { getAccessToken } from '@logto/next/server-actions';
import { logtoConfig } from './logto';
import GetAccessToken from './get-access-token';
export default async function Home() {
return (
<main>
<GetAccessToken
onGetAccessToken={async () => {
'use server';
return getAccessToken(logtoConfig, 'https://shopping.your-app.com/api');
}}
/>
</main>
);
}
'use client';
type Props = {
onGetAccessToken: () => Promise<string>;
};
const GetAccessToken = ({ onGetAccessToken }: Props) => {
return (
<button
onClick={async () => {
const token = await onGetAccessToken();
console.log(token);
}}
>
取得存取權杖 (查看控制台日誌)
</button>
);
};
export default GetAccessToken;
此方法將返回一個 JWT 存取權杖 (Access token),當使用者擁有相關權限時,可以用來存取 API 資源。如果當前快取的存取權杖 (Access token) 已過期,此方法將自動嘗試使用重新整理權杖 (Refresh token) 獲取新的存取權杖 (Access token)。
如果需要在伺服器元件中獲取存取權杖,可以使用 getAccessTokenRSC
函數:
import { getAccessTokenRSC } from '@logto/next/server-actions';
import { logtoConfig } from './logto';
export default async function Home() {
const accessToken = await getAccessTokenRSC(logtoConfig, 'https://shopping.your-app.com/api');
return (
<main>
<p>Access token: {accessToken}</p>
</main>
);
}
HTTP 不允許在流式傳輸開始後設置 cookie,getAccessTokenRSC
無法更新 cookie 值,因此如果存取權杖被刷新,將不會在會話中持久化。建議在客戶端或路由處理器中使用 getAccessToken
函數。
獲取組織權杖
如果你對組織 (Organization) 不熟悉,請閱讀 🏢 組織(多租戶,Multi-tenancy) 以開始了解。
在配置 Logto client 時,你需要新增 UserScope.Organizations
權限範圍 (scope):
import { UserScope } from '@logto/next';
export const logtoConfig = {
// ...other configs
scopes: [UserScope.Organizations],
};
使用者登入後,你可以為使用者獲取組織權杖 (organization token):
在客戶端元件中不允許定義內嵌的 "use server" 註解的伺服器操作。我們必須透過從伺服器元件傳遞屬性來實現。
import { getOrganizationToken } from '@logto/next/server-actions';
import { logtoConfig } from './logto';
import GetOrganizationToken from './get-organization-token';
export default async function Home() {
return (
<main>
<GetOrganizationToken
onGetOrganizationToken={async () => {
'use server';
return getOrganizationToken(logtoConfig, 'organization-id');
}}
/>
</main>
);
}
'use client';
type Props = {
onGetOrganizationToken: () => Promise<string>;
};
const GetOrganizationToken = ({ onGetOrganizationToken }: Props) => {
return (
<button
onClick={async () => {
const token = await onGetOrganizationToken();
console.log(token);
}}
>
取得組織權杖 (查看控制台日誌)
</button>
);
};
export default GetOrganizationToken;
如果需要在伺服器元件中獲取組織權杖,可以使用 getOrganizationTokenRSC
函數:
import { getOrganizationTokenRSC } from '@logto/next/server-actions';
import { logtoConfig } from './logto';
export default async function Home() {
const token = await getOrganizationTokenRSC(logtoConfig, 'organization-id');
return (
<main>
<p>Organization token: {token}</p>
</main>
);
}
HTTP 不允許在流式傳輸開始後設置 cookie,getOrganizationTokenRSC
無法更新 cookie 值,因此如果存取權杖被刷新,將不會在會話中持久化。建議在客戶端或路由處理器中使用 getOrganizationToken
函數。
使用外部會話儲存
SDK 預設使用 cookies 來儲存加密的會話資料。這種方法安全且不需要額外的基礎設施,特別適合在像 Vercel 這樣的無伺服器環境中運作。
然而,有時你可能需要將會話資料儲存在外部。例如,當你的會話資料對 cookies 來說過大時,特別是當你需要同時維持多個活躍的組織會話時。在這些情況下,你可以使用 sessionWrapper
選項來實現外部會話儲存:
import { MemorySessionWrapper } from './storage';
export const config = {
// ...
sessionWrapper: new MemorySessionWrapper(),
};
import { randomUUID } from 'node:crypto';
import { type SessionWrapper, type SessionData } from '@logto/next';
export class MemorySessionWrapper implements SessionWrapper {
private readonly storage = new Map<string, unknown>();
async wrap(data: unknown, _key: string): Promise<string> {
const sessionId = randomUUID();
this.storage.set(sessionId, data);
return sessionId;
}
async unwrap(value: string, _key: string): Promise<SessionData> {
if (!value) {
return {};
}
const data = this.storage.get(value);
return data ?? {};
}
}
上述實作使用簡單的記憶體內儲存。在生產環境中,你可能會想使用更持久的儲存解決方案,例如 Redis 或資料庫。