跳到主要内容

为你的传统 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 个依赖:

  1. @logto/js: Logto 的 JavaScript 核心 SDK。
  2. node-fetch: 在 Node.js 运行时中提供与 window.fetch 兼容的 API 的最小代码。
  3. express-session: 一个会话中间件,我们将使用会话来存储用户令牌。
  4. js-base64: 另一个 Base64 转码器。
npm i @logto/js node-fetch@v2 express-session js-base64

使用会话

当用户登录时,他们将获得一组令牌(访问令牌 (Access Token)、ID 令牌 (ID Token)、刷新令牌 (Refresh Token))和交互数据,会话是存储它们的理想位置。

我们在上一步中已经安装了 express-session,现在让我们简单地添加以下代码来设置它:

// app.js

const session = require('express-session');

app.use(
session({
secret: 'keyboard cat', // 更改为你自己的密钥
cookie: { maxAge: 86400 },
})
);

实现用户认证功能

提示

在以下代码片段中,我们假设应用运行在 http://localhost:3000

在这一步中,我们需要实现以下认证 (Authentication) 功能:

  1. getSignInUrl: 构建并返回 Logto 授权服务器的完整 URL,用户将被重定向到该 URL。
  2. handleSignIn: 解析认证过程完成后的回调 URL,获取代码查询参数,然后获取令牌(访问令牌、刷新令牌和 ID 令牌)以完成登录过程。
  3. refreshTokens: 使用刷新令牌交换新的访问令牌。
提示

你可以在管理控制台的应用详情页面找到并复制“应用密钥”:

App Secret
// 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;
};

登录

在我们深入细节之前,这里是终端用户体验的快速概述。登录过程可以简化如下:

  1. 你的应用调用登录方法。
  2. 用户被重定向到 Logto 登录页面。对于原生应用,将打开系统浏览器。
  3. 用户登录并被重定向回你的应用(配置为重定向 URI)。
关于基于重定向的登录
  1. 此认证 (Authentication) 过程遵循 OpenID Connect (OIDC) 协议,Logto 强制执行严格的安全措施以保护用户登录。
  2. 如果你有多个应用程序,可以使用相同的身份提供商 (IdP)(日志 (Logto))。一旦用户登录到一个应用程序,当用户访问另一个应用程序时,Logto 将自动完成登录过程。

要了解有关基于重定向的登录的原理和好处的更多信息,请参阅 Logto 登录体验解释


配置重定向 URI

让我们切换到 Logto Console 的应用详情页面。添加一个重定向 URI http://localhost:3000/callback 并点击“保存更改”。

Logto Console 中的重定向 URI

实现登录路由

备注

在调用 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