为你的传统 Web 应用添加认证 (Authentication)
本指南假设你已经在管理控制台中创建了一个类型为“Traditional web”的应用程序。
你的应用可能运行在服务器端而不是浏览器中,使用像 Django、Laravel 等框架。这被称为传统 Web 应用。如果你在此页面找不到合适的 SDK,可能需要手动集成。
本文将指导你如何一步步完成它。我们以 Node.js 中的 Express 为例。
本文不仅适用于 Express,如果你使用其他框架甚至其他语言,可以将 @logto/js
替换为其他语言的核心 SDK,然后调整一些步骤。
获取源代码
你可以访问 GitHub 获取本指南的最终代码。
启动一个 Express 项目
使用 express-generator
,你可以快速启动一个 Express 项目。
mkdir express-logto
cd express-logto
npx express-generator
安装依赖
示例应用需要 4 个依赖:
- @logto/js:Logto 的 JavaScript 核心 SDK。
- node-fetch:在 Node.js 运行时中提供
window.fetch
兼容 API 的最小代码。 - express-session:一个会话中间件,我们将使用会话来存储用户令牌。
- js-base64:另一个 Base64 转码器。
- npm
- Yarn
- pnpm
npm i @logto/js node-fetch@v2 express-session js-base64
yarn add @logto/js node-fetch@v2 express-session js-base64
pnpm add @logto/js node-fetch@v2 express-session js-base64
使用会话
当用户登录时,他们将获得一组令牌(访问令牌、ID 令牌、刷新令牌)和交互数据,会话是存储它们的绝佳位置。
我们在上一步中已经安装了 express-session,现在让我们简单地添加以下代码来设置它:
// app.js
const session = require('express-session');
app.use(
session({
secret: 'keyboard cat', // 更改为你自己的密钥
cookie: { maxAge: 86400 },
})
);
实现用户认证 (Authentication) 功能
我们假设应用程序运行在 http://localhost:3000
,以下代码片段中会用到。
在这一步中,我们需要实现以下认证 (Authentication) 功能:
getSignInUrl
:构建并返回 Logto 授权 (Authorization) 服务器的完整 URL,用户将被重定向到该 URL。handleSignIn
:解析认证 (Authentication) 过程完成后的回调 URL,获取代码查询参数,然后获取令牌(访问令牌、刷新令牌和 ID 令牌)以完成登录过程。refreshTokens
:使用刷新令牌交换新的访问令牌。
你可以在管理控制台的应用详情页面找到并复制“应用密钥”:
// logto.js
const {
withReservedScopes,
fetchOidcConfig,
discoveryPath,
generateSignInUri,
verifyAndParseCodeFromCallbackUri,
fetchTokenByAuthorizationCode,
fetchTokenByRefreshToken,
} = require('@logto/js');
const fetch = require('node-fetch');
const { randomFillSync, createHash } = require('crypto');
const { fromUint8Array } = require('js-base64');
const config = {
endpoint: 'https://logto.dev',
appId: 'foo',
appSecret: '<your-app-secret-copied-from-console>',
redirectUri: 'http://localhost:3000/callback', // 你可能需要将其替换为你的应用的生产地址
scopes: withReservedScopes().split(' '),
};
const requester = (input, init) => {
const { appId, appSecret } = config;
return fetch(input, {
...init,
headers: {
...init?.headers,
authorization: `Basic ${Buffer.from(`${appId}:${appSecret}`, 'utf8').toString('base64')}`,
},
});
};
const generateRandomString = (length = 64) => {
return fromUint8Array(randomFillSync(new Uint8Array(length)), true);
};
const generateCodeChallenge = async (codeVerifier) => {
const encodedCodeVerifier = new TextEncoder().encode(codeVerifier);
const hash = createHash('sha256');
hash.update(encodedCodeVerifier);
const codeChallenge = hash.digest();
return fromUint8Array(codeChallenge, true);
};
const getOidcConfig = async () => {
return fetchOidcConfig(new URL(discoveryPath, config.endpoint).toString(), requester);
};
exports.getSignInUrl = async () => {
const { authorizationEndpoint } = await getOidcConfig();
const codeVerifier = generateRandomString();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = generateRandomString();
const { redirectUri, scopes, appId: clientId } = config;
const signInUri = generateSignInUri({
authorizationEndpoint,
clientId,
redirectUri: redirectUri,
codeChallenge,
state,
scopes,
});
return { redirectUri, codeVerifier, state, signInUri };
};
exports.handleSignIn = async (signInSession, callbackUri) => {
const { redirectUri, state, codeVerifier } = signInSession;
const code = verifyAndParseCodeFromCallbackUri(callbackUri, redirectUri, state);
const { appId: clientId } = config;
const { tokenEndpoint } = await getOidcConfig();
const codeTokenResponse = await fetchTokenByAuthorizationCode(
{
clientId,
tokenEndpoint,
redirectUri,
codeVerifier,
code,
},
requester
);
return codeTokenResponse;
};
exports.refreshTokens = async (refreshToken) => {
const { appId: clientId, scopes } = config;
const { tokenEndpoint } = await getOidcConfig();
const tokenResponse = await fetchTokenByRefreshToken(
{
clientId,
tokenEndpoint,
refreshToken,
scopes,
},
requester
);
return tokenResponse;
};
登录
在我们深入细节之前,这里是终端用户体验的快速概述。登录过程可以简化如下:
- 你的应用调用登录方法。
- 用户被重定向到 Logto 登录页面。对于原生应用,将打开系统浏览器。
- 用户登录并被重定向回你的应用(配置为重定向 URI)。
关于基于重定向的登录
- 此认证 (Authentication) 过程遵循 OpenID Connect (OIDC) 协议,Logto 强制执行严格的安全措施以保护用户登录。
- 如果你有多个应用程序,可以使用相同的身份提供商 (IdP)(日志 (Logto))。一旦用户登录到一个应用程序,当用户访问另一个应用程序时,Logto 将自动完成登录过程。
要了解有关基于重定向的登录的原理和好处的更多信息,请参阅 Logto 登录体验解释。
配置重定向 URI
让我们切换到 Logto Console 的应用详情页面。添加一个重定向 URI http://localhost:3000/callback
并点击“保存更改”。
实现登录路由
在调用 getSignInUrl()
之前,请确保你已在管理控制台中正确配置了重定向 URI。
在 Express 中创建一个登录路由:
const { getSignInUrl } = require('./logto');
app.get('/sign-in', async (req, res) => {
const { redirectUri, codeVerifier, state, signInUri } = await getSignInUrl();
req.session.signIn = { codeVerifier, state, redirectUri };
res.redirect(signInUri);
});
以及一个处理回调的路由:
app.get('/callback', async (req, res) => {
if (!req.session.signIn) {
res.send('Bad request.');
return;
}
const response = await handleSignIn(
req.session.signIn,
`${req.protocol}://${req.get('host')}${req.originalUrl}`
);
req.session.tokens = {
...response,
expiresAt: response.expiresIn + Date.now(),
idToken: decodeIdToken(response.idToken),
};
req.session.signIn = null;
res.redirect('/');
});
登出
TODO: 链接到用户参考中的“会话 & cookies”章节。
你可以清除会话中的令牌以使用户从此应用程序中登出。并查看此链接以了解更多关于“登出”的信息。
app.get('/sign-out', (req, res) => {
req.session.tokens = null;
res.send('Sign out successful');
});
访问受保护的资源
创建一个名为 withAuth
的中间件,将 auth
对象附加到 req
,并验证用户是否已登录。
// auth.js
const { decodeIdToken } = require('@logto/js');
const { refreshTokens } = require('./logto');
const withAuth =
({ requireAuth } = { requireAuth: true }) =>
async (req, res, next) => {
if (requireAuth && !req.session.tokens) {
res.redirect('/sign-in');
return;
}
if (req.session.tokens) {
if (req.session.tokens.expiresAt >= Date.now()) {
// 访问令牌过期,刷新以获取新令牌
try {
const response = await refreshTokens(req.session.tokens.refreshToken);
req.session.tokens = {
...response,
expiresAt: response.expiresIn + Date.now(),
idToken: decodeIdToken(response.idToken),
};
} catch {
// 交换失败,重定向到登录
res.redirect('/sign-in');
return;
}
}
req.auth = req.session.tokens.idToken.sub;
}
next();
};
module.exports = withAuth;
创建 index
页面,为访客显示登录链接,为已登录用户显示前往个人资料的链接:
router.get('/', withAuth({ requireAuth: false }), function (req, res, next) {
res.render('index', { auth: Boolean(req.auth) });
});
extends layout
block content
h1 Hello logto
if auth
p: a(href="/user") Go to profile
else
p: a(href="/sign-in") Click here to sign in
创建 user
页面,显示 userId
(主体 (Subject)
):
app.get('/user', withAuth(), (req, res, next) => {
res.render('user', { userId: req.auth });
});
extends layout
block content
h1 Hello logto
p userId: #{userId}
进一步阅读
终端用户流程:认证流程、账户流程和组织流程 配置连接器 保护你的 API在 GPT 操作中认证 (Authentication) 用户:构建个人日程助手