From 989c3c66ad2dc2b4a1fd12b192a3eb7333412d42 Mon Sep 17 00:00:00 2001 From: qcqcqc <1220204124@zust.edu.cn> Date: Tue, 28 Oct 2025 11:07:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9C=A8accessToken=E7=9A=84endpoint?= =?UTF-8?q?=E5=92=8Coauth=E7=9A=84aspect=E4=B8=AD=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E4=BA=86PKCE=E7=9B=B8=E5=85=B3=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/aspects/AspectDict.ts | 138 ++++++++++++++++++++------------------ src/aspects/oauth.ts | 34 ++++++---- src/endpoints/oauth.ts | 73 +++++++++++++++++++- 3 files changed, 164 insertions(+), 81 deletions(-) diff --git a/src/aspects/AspectDict.ts b/src/aspects/AspectDict.ts index d26e9f44b..cb3fb52d3 100644 --- a/src/aspects/AspectDict.ts +++ b/src/aspects/AspectDict.ts @@ -21,7 +21,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 合并用户账号,将一个用户的数据迁移到另一个用户 * @param from 源用户 ID @@ -31,7 +31,7 @@ export type AspectDict = { params: { from: string; to: string }, context: BackendRuntimeContext ) => Promise; - + /** * 刷新微信公众号用户信息(昵称、头像、性别等) */ @@ -39,7 +39,7 @@ export type AspectDict = { params: {}, context: BackendRuntimeContext ) => Promise; - + /** * 获取微信小程序用户的手机号 * @param code 微信小程序获取手机号的 code @@ -50,7 +50,7 @@ export type AspectDict = { params: { code: string; env: WechatMpEnv }, context: BackendRuntimeContext ) => Promise; - + /** * 通过手机号绑定当前登录用户 * @param mobile 手机号 @@ -65,7 +65,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 通过邮箱绑定当前登录用户 * @param email 邮箱地址 @@ -80,7 +80,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 通过手机号和验证码登录 * @param mobile 手机号 @@ -98,7 +98,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 验证用户密码是否正确 * @param password 密码(明文或 SHA1 密文,根据系统配置) @@ -111,7 +111,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 通过账号(手机号/邮箱/登录名)和密码登录 * @param account 账号(可以是手机号、邮箱或登录名) @@ -127,7 +127,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 通过邮箱和验证码登录 * @param email 邮箱地址 @@ -145,7 +145,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 微信公众号登录 * @param code 微信授权 code @@ -165,7 +165,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 用户登出,使指定 token 失效 * @param tokenValue 要失效的 token @@ -174,7 +174,7 @@ export type AspectDict = { params: { tokenValue: string }, context: BackendRuntimeContext ) => Promise; - + /** * 微信小程序登录 * @param code 微信授权 code @@ -191,7 +191,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 微信原生 APP 登录 * @param code 微信授权 code @@ -208,7 +208,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 同步微信小程序用户信息(昵称、头像等) * @param nickname 昵称 @@ -233,7 +233,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 唤醒寄生用户(将 shadow 状态的用户激活) * @param id 用户 ID @@ -247,7 +247,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 刷新 token,延长有效期 * @param tokenValue 当前 token @@ -263,7 +263,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 通过手机号发送验证码 * @param mobile 手机号 @@ -279,7 +279,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 通过邮箱发送验证码 * @param email 邮箱地址 @@ -295,7 +295,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 根据域名和应用类型获取应用信息,并检查版本兼容性 * @param version 客户端版本号 @@ -315,7 +315,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 生成微信 JS-SDK 签名,用于调用微信 JS 接口 * @param url 当前页面 URL @@ -334,7 +334,7 @@ export type AspectDict = { timestamp: number; appId: string; }>; - + /** * 更新平台或系统的配置信息 * @param entity 实体类型(platform 或 system) @@ -349,7 +349,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 更新平台、系统或应用的样式配置 * @param entity 实体类型(platform/system/application) @@ -364,7 +364,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 更新应用的配置信息 * @param entity 实体类型(application) @@ -379,7 +379,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 切换到指定用户(管理员扮演用户功能) * @param userId 目标用户 ID @@ -390,7 +390,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 获取小程序无限制二维码 * @param wechatQrCodeId 微信二维码 ID @@ -400,7 +400,7 @@ export type AspectDict = { wechatQrCodeId: string, context: BackendRuntimeContext ) => Promise; - + /** * 创建微信登录会话(用于扫码登录场景) * @param type 登录类型(login-登录,bind-绑定) @@ -414,7 +414,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 解绑微信用户 * @param wechatUserId 微信用户 ID @@ -429,7 +429,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 通过微信登录会话 ID 完成登录(Web 端扫码登录确认) * @param wechatLoginId 微信登录会话 ID @@ -443,7 +443,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 从 URL 中提取网页信息(标题、发布时间、图片列表) * @param url 网页 URL @@ -454,7 +454,7 @@ export type AspectDict = { publishDate: number | undefined; imageList: string[]; }>; - + /** * 获取用户可用的修改密码方式 * @param userId 用户 ID @@ -464,7 +464,7 @@ export type AspectDict = { params: { userId: string }, context: BackendRuntimeContext ) => Promise; - + /** * 修改用户密码 * @param userId 用户 ID @@ -484,7 +484,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise<{ result: string; times?: number }>; - + /** * 创建或获取会话(用于客服系统等场景) * @param data 微信事件数据(可选) @@ -502,7 +502,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 上传素材到微信服务器 * @param params 包含文件信息、应用 ID、素材类型等 @@ -512,7 +512,7 @@ export type AspectDict = { params: any, context: BackendRuntimeContext ) => Promise<{ mediaId: string }>; - + /** * 获取微信公众号当前使用的自定义菜单配置 * @param applicationId 应用 ID @@ -524,7 +524,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 获取微信公众号自定义菜单配置(包括默认和个性化菜单) * @param applicationId 应用 ID @@ -536,7 +536,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 创建微信公众号自定义菜单 * @param applicationId 应用 ID @@ -551,7 +551,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 创建微信公众号个性化菜单(针对特定用户群体) * @param applicationId 应用 ID @@ -566,7 +566,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 删除微信公众号个性化菜单 * @param applicationId 应用 ID @@ -579,7 +579,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 删除微信公众号自定义菜单 * @param applicationId 应用 ID @@ -590,7 +590,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 批量获取微信公众号图文消息素材 * @param applicationId 应用 ID @@ -608,7 +608,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 获取微信公众号单个图文消息素材 * @param applicationId 应用 ID @@ -622,7 +622,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 批量获取微信素材列表 * @param applicationId 应用 ID @@ -640,7 +640,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 获取微信素材 * @param applicationId 应用 ID @@ -656,7 +656,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 删除微信永久素材 * @param applicationId 应用 ID @@ -669,7 +669,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 创建微信公众号用户标签 * @param applicationId 应用 ID @@ -683,7 +683,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 获取微信公众号所有用户标签 * @param applicationId 应用 ID @@ -695,7 +695,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 编辑微信公众号用户标签 * @param applicationId 应用 ID @@ -710,7 +710,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 删除微信公众号用户标签 * @param applicationId 应用 ID @@ -725,7 +725,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 同步微信公众号消息模板到本地 * @param applicationId 应用 ID @@ -737,7 +737,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 获取已注册的消息类型列表 * @returns 返回消息类型数组 @@ -746,7 +746,7 @@ export type AspectDict = { params: {}, content: BackendRuntimeContext ) => Promise; - + /** * 同步单个微信公众号用户标签到微信服务器 * @param applicationId 应用 ID @@ -759,7 +759,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 一键同步微信公众号用户标签(从微信服务器同步到本地) * @param applicationId 应用 ID @@ -770,7 +770,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 获取指定标签下的微信用户列表 * @param applicationId 应用 ID @@ -784,7 +784,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 批量为用户打标签 * @param applicationId 应用 ID @@ -799,7 +799,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 批量为用户取消标签 * @param applicationId 应用 ID @@ -814,7 +814,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 获取用户身上的标签列表 * @param applicationId 应用 ID @@ -828,7 +828,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 获取微信公众号用户列表 * @param applicationId 应用 ID @@ -842,7 +842,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 为单个用户设置标签列表 * @param applicationId 应用 ID @@ -857,7 +857,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 从微信服务器同步用户标签到本地 * @param applicationId 应用 ID @@ -870,7 +870,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 将本地用户标签同步到微信服务器 * @param applicationId 应用 ID @@ -885,7 +885,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 同步短信模板(从服务商同步到本地) * @param systemId 系统 ID @@ -898,7 +898,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 获取应用的登录方式配置列表 * @param applicationId 应用 ID @@ -910,7 +910,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 根据登录方式 ID 列表删除应用的登录方式配置 * @param passportIds 登录方式 ID 列表 @@ -921,7 +921,7 @@ export type AspectDict = { }, content: BackendRuntimeContext ) => Promise; - + /** * 通过 OAuth 2.0 第三方登录 * @param code OAuth 授权码 @@ -937,7 +937,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * 创建 OAuth 登录/绑定状态码 * @param providerId OAuth 提供商 ID @@ -953,7 +953,7 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; - + /** * OAuth 2.0 授权确认(用户同意或拒绝授权) * @param response_type 响应类型(固定为 "code") @@ -972,12 +972,16 @@ export type AspectDict = { scope: string; state: string; action: "grant" | "deny"; + + // PKCE 支持 + code_challenge?: string; + code_challenge_method?: 'plain' | 'S256'; }, context: BackendRuntimeContext ) => Promise<{ redirectUri: string; }> - + /** * 获取 OAuth 客户端应用信息 * @param client_id 客户端应用 ID diff --git a/src/aspects/oauth.ts b/src/aspects/oauth.ts index f9ab7e5da..f59466ccf 100644 --- a/src/aspects/oauth.ts +++ b/src/aspects/oauth.ts @@ -41,7 +41,7 @@ export async function loginByOauth(params: { filter: { state: stateCode, }, - }, {}) + }, { dontCollect: true }) assert(state, '无效的 state 参数'); assert(state.provider?.ableState && state.provider?.ableState === 'enabled', '该 OAuth 提供商已被禁用'); @@ -60,7 +60,7 @@ export async function loginByOauth(params: { filter: { id: state.id, } - }, {}); + }, { dontCollect: true }); // 使用 code 换取 access_token 并获取用户信息 const { oauthUserInfo, accessToken, refreshToken, accessTokenExp, refreshTokenExp } = await fetchOAuthUserInfo( @@ -87,7 +87,7 @@ export async function loginByOauth(params: { providerUserId: oauthUserInfo.providerUserId, providerConfigId: state.providerId!, } - }, {}) + }, { dontCollect: true }) // 已登录的情况 if (islogginedIn) { @@ -130,7 +130,7 @@ export async function loginByOauth(params: { applicationId, stateId: state.id, } - }, {}); + }, { dontCollect: true }); // 返回当前 token const tokenValue = context.getTokenValue()!; @@ -171,7 +171,7 @@ export async function loginByOauth(params: { filter: { id: existingOAuthUser.id, } - }, {}); + }, { dontCollect: true }); await loadTokenInfo(tokenValue, context); closeRootMode(); @@ -219,7 +219,7 @@ export async function loginByOauth(params: { filter: { id: newUserId, } - }, {}); + }, { dontCollect: true }); await loadTokenInfo(tokenValue, context); closeRootMode(); @@ -248,7 +248,7 @@ export async function getOAuthClientInfo(params: { systemId: systemId, ableState: "enabled", } - }, {}) + }, { dontCollect: true }) // 如果还有正在生效的授权,说明已经授权过了 const [hasAuth] = await context.select("oauthUserAuthorization", { @@ -279,7 +279,7 @@ export async function getOAuthClientInfo(params: { userId: currentUserId, } } - }, {}) + }, { dontCollect: true }) if (hasAuth) { console.log("用户已有授权记录:", currentUserId, hasAuth.id, hasAuth.userId, hasAuth.applicationId, hasAuth.usageState); @@ -318,7 +318,7 @@ export async function createOAuthState(params: { type, state } - }, {}) + }, { dontCollect: true }) closeRootMode(); return state; @@ -330,8 +330,12 @@ export async function authorize(params: { scope?: string; state?: string; action: 'grant' | 'deny'; + + // PKCE 支持 + code_challenge?: string; + code_challenge_method?: 'plain' | 'S256'; }, context: BRC) { - const { response_type, client_id, redirect_uri, scope, state, action } = params; + const { response_type, client_id, redirect_uri, scope, state, action, code_challenge, code_challenge_method } = params; if (response_type !== 'code') { throw new OakUserException('不支持的 response_type 类型'); @@ -350,7 +354,7 @@ export async function authorize(params: { id: client_id, systemId: systemId, } - }, {}) + }, { dontCollect: true }) if (!oauthApp) { throw new OakUserException('未经授权的客户端应用'); @@ -368,7 +372,7 @@ export async function authorize(params: { usageState: action === 'grant' ? 'unused' : 'denied', authorizedAt: Date.now(), } - }, {}) + }, { dontCollect: true }) if (action === 'deny') { const params = new URLSearchParams(); @@ -407,8 +411,12 @@ export async function authorize(params: { userId: context.getCurrentUserId()!, scope: scope === undefined ? [] : [scope], expiresAt: Date.now() + 10 * 60 * 1000, // 10分钟后过期 + + // PKCE 支持 + codeChallenge: code_challenge, + codeChallengeMethod: code_challenge_method || 'plain', } - }, {}) + }, { dontCollect: true }) // 更新记录 await context.operate("oauthUserAuthorization", { diff --git a/src/endpoints/oauth.ts b/src/endpoints/oauth.ts index 7811bbd18..7786f11d4 100644 --- a/src/endpoints/oauth.ts +++ b/src/endpoints/oauth.ts @@ -8,6 +8,27 @@ import { applicationProjection, extraFileProjection } from "../types/Projection" import { composeFileUrl } from "../utils/cos/index.backend"; import assert from "assert"; import { checkOauthTokenAvaliable } from "../utils/oauth"; +import { createHash } from "crypto"; + +// PKCE 验证函数 +async function verifyPKCE( + verifier: string, + challenge: string, + method: 'plain' | 'S256' +): Promise { + if (method === 'plain') { + return verifier === challenge; + } else if (method === 'S256') { + const hash = createHash('sha256').update(verifier).digest(); + const computed = hash.toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + return computed === challenge; + } + return false; +} + const oauthTokenEndpoint: Endpoint> = { name: "获取OAuth Token", @@ -16,7 +37,21 @@ const oauthTokenEndpoint: Endpoint type: "free", fn: async (contextBuilder, params, header, req, body) => { const context = await contextBuilder() - const { client_id, client_secret, grant_type, code, redirect_uri } = body as { client_id: string, client_secret: string, grant_type: string, code?: string, redirect_uri?: string }; + const { + client_id, + client_secret, + grant_type, + code, + redirect_uri, + code_verifier // PKCE支持 + } = body as { + client_id: string, + client_secret: string, + grant_type: string, + code?: string, + redirect_uri?: string + code_verifier?: string + }; const [app] = await context.select("oauthApplication", { data: { id: 1, @@ -61,6 +96,8 @@ const oauthTokenEndpoint: Endpoint userId: 1, expiresAt: 1, usedAt: 1, + codeChallenge: 1, + codeChallengeMethod: 1, }, filter: { code, @@ -76,6 +113,40 @@ const oauthTokenEndpoint: Endpoint }; } + // PKCE 验证 + if (authCodeRecord.codeChallenge) { + if (!code_verifier) { + await context.commit(); + return { + statusCode: 400, + data: { + error: "invalid_request", + error_description: "code_verifier is required", + success: false + } + }; + } + + // 验证 code_verifier + const isValid = await verifyPKCE( + code_verifier, + authCodeRecord.codeChallenge as string, + authCodeRecord.codeChallengeMethod as 'plain' | 'S256' + ); + + if (!isValid) { + await context.commit(); + return { + statusCode: 400, + data: { + error: "invalid_grant", + error_description: "Invalid code_verifier", + success: false + } + }; + } + } + // 验证redirect_uri if (authCodeRecord.redirectUri !== redirect_uri) { await context.commit();