diff --git a/src/aspects/AspectDict.ts b/src/aspects/AspectDict.ts index c8ab4be20..aa3bcf7bc 100644 --- a/src/aspects/AspectDict.ts +++ b/src/aspects/AspectDict.ts @@ -8,6 +8,12 @@ import { BackendRuntimeContext } from '../context/BackendRuntimeContext'; import { WechatPublicEventData, WechatMpEventData } from 'oak-external-sdk'; export type AspectDict = { + /** + * 使用小程序 token 登录 Web 端 + * @param mpToken 小程序的 token + * @param env Web 环境信息 + * @returns 返回 Web 端的 token + */ loginWebByMpToken: ( params: { mpToken: string; @@ -15,18 +21,42 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 合并用户账号,将一个用户的数据迁移到另一个用户 + * @param from 源用户 ID + * @param to 目标用户 ID + */ mergeUser: ( params: { from: string; to: string }, context: BackendRuntimeContext ) => Promise; + + /** + * 刷新微信公众号用户信息(昵称、头像、性别等) + */ refreshWechatPublicUserInfo: ( params: {}, context: BackendRuntimeContext ) => Promise; + + /** + * 获取微信小程序用户的手机号 + * @param code 微信小程序获取手机号的 code + * @param env 小程序环境信息 + * @returns 返回用户手机号 + */ getWechatMpUserPhoneNumber: ( params: { code: string; env: WechatMpEnv }, context: BackendRuntimeContext ) => Promise; + + /** + * 通过手机号绑定当前登录用户 + * @param mobile 手机号 + * @param captcha 验证码 + * @param env 环境信息 + */ bindByMobile: ( params: { mobile: string; @@ -35,6 +65,13 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 通过邮箱绑定当前登录用户 + * @param email 邮箱地址 + * @param captcha 验证码 + * @param env 环境信息 + */ bindByEmail: ( params: { email: string; @@ -43,6 +80,15 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 通过手机号和验证码登录 + * @param mobile 手机号 + * @param captcha 验证码 + * @param disableRegister 是否禁止自动注册,true 时账号不存在会报错 + * @param env 环境信息 + * @returns 返回登录 token + */ loginByMobile: ( params: { mobile: string; @@ -52,6 +98,12 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 验证用户密码是否正确 + * @param password 密码(明文或 SHA1 密文,根据系统配置) + * @param env 环境信息 + */ verifyPassword: ( params: { password: string; @@ -59,6 +111,14 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 通过账号(手机号/邮箱/登录名)和密码登录 + * @param account 账号(可以是手机号、邮箱或登录名) + * @param password 密码(明文或 SHA1 密文,根据系统配置) + * @param env 环境信息 + * @returns 返回登录 token + */ loginByAccount: ( params: { account: string; @@ -67,6 +127,15 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 通过邮箱和验证码登录 + * @param email 邮箱地址 + * @param captcha 验证码 + * @param disableRegister 是否禁止自动注册,true 时账号不存在会报错 + * @param env 环境信息 + * @returns 返回登录 token + */ loginByEmail: ( params: { email: string; @@ -76,6 +145,14 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 微信公众号登录 + * @param code 微信授权 code + * @param env Web 环境信息 + * @param wechatLoginId 可选的微信登录 ID(用于扫码登录场景) + * @returns 返回登录 token + */ loginWechat: ( { code, @@ -88,10 +165,22 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 用户登出,使指定 token 失效 + * @param tokenValue 要失效的 token + */ logout: ( params: { tokenValue: string }, context: BackendRuntimeContext ) => Promise; + + /** + * 微信小程序登录 + * @param code 微信授权 code + * @param env 小程序环境信息 + * @returns 返回登录 token + */ loginWechatMp: ( { code, @@ -102,6 +191,13 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 微信原生 APP 登录 + * @param code 微信授权 code + * @param env 原生 APP 环境信息 + * @returns 返回登录 token + */ loginWechatNative: ( { code, @@ -112,6 +208,15 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 同步微信小程序用户信息(昵称、头像等) + * @param nickname 昵称 + * @param avatarUrl 头像 URL + * @param encryptedData 加密数据 + * @param iv 加密算法的初始向量 + * @param signature 签名 + */ syncUserInfoWechatMp: ( { nickname, @@ -128,6 +233,13 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 唤醒寄生用户(将 shadow 状态的用户激活) + * @param id 用户 ID + * @param env 环境信息 + * @returns 返回 token + */ wakeupParasite: ( params: { id: string; @@ -135,6 +247,14 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 刷新 token,延长有效期 + * @param tokenValue 当前 token + * @param env 环境信息 + * @param applicationId 应用 ID + * @returns 返回新的 token + */ refreshToken: ( params: { tokenValue: string; @@ -143,6 +263,14 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 通过手机号发送验证码 + * @param mobile 手机号 + * @param env 环境信息 + * @param type 验证码类型:login-登录,changePassword-修改密码,confirm-确认操作 + * @returns 返回验证码 ID + */ sendCaptchaByMobile: ( params: { mobile: string; @@ -151,6 +279,14 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 通过邮箱发送验证码 + * @param email 邮箱地址 + * @param env 环境信息 + * @param type 验证码类型:login-登录,changePassword-修改密码,confirm-确认操作 + * @returns 返回验证码 ID + */ sendCaptchaByEmail: ( params: { email: string; @@ -159,6 +295,16 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 根据域名和应用类型获取应用信息,并检查版本兼容性 + * @param version 客户端版本号 + * @param type 应用类型(web/wechatMp/wechatPublic/native) + * @param domain 域名 + * @param data 需要返回的应用数据字段 + * @param appId 可选的应用 ID + * @returns 返回应用 ID + */ getApplication: ( params: { version: string; @@ -169,6 +315,13 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 生成微信 JS-SDK 签名,用于调用微信 JS 接口 + * @param url 当前页面 URL + * @param env Web 环境信息 + * @returns 返回签名信息(signature、noncestr、timestamp、appId) + */ signatureJsSDK: ( params: { url: string; @@ -181,6 +334,13 @@ export type AspectDict = { timestamp: number; appId: string; }>; + + /** + * 更新平台或系统的配置信息 + * @param entity 实体类型(platform 或 system) + * @param entityId 实体 ID + * @param config 配置对象 + */ updateConfig: ( params: { entity: 'platform' | 'system'; @@ -189,6 +349,13 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 更新平台、系统或应用的样式配置 + * @param entity 实体类型(platform/system/application) + * @param entityId 实体 ID + * @param style 样式对象 + */ updateStyle: ( params: { entity: 'platform' | 'system' | 'application'; @@ -197,6 +364,13 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 更新应用的配置信息 + * @param entity 实体类型(application) + * @param entityId 应用 ID + * @param config 应用配置对象 + */ updateApplicationConfig: ( params: { entity: 'application'; @@ -205,16 +379,34 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 切换到指定用户(管理员扮演用户功能) + * @param userId 目标用户 ID + */ switchTo: ( params: { userId: string; }, context: BackendRuntimeContext ) => Promise; + + /** + * 获取小程序无限制二维码 + * @param wechatQrCodeId 微信二维码 ID + * @returns 返回二维码图片数据(Base64 字符串) + */ getMpUnlimitWxaCode: ( wechatQrCodeId: string, context: BackendRuntimeContext ) => Promise; + + /** + * 创建微信登录会话(用于扫码登录场景) + * @param type 登录类型(login-登录,bind-绑定) + * @param interval 会话有效期(毫秒) + * @returns 返回登录会话 ID + */ createWechatLogin: ( params: { type: EntityDict['wechatLogin']['Schema']['type']; @@ -222,6 +414,13 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 解绑微信用户 + * @param wechatUserId 微信用户 ID + * @param captcha 可选的验证码 + * @param mobile 可选的手机号 + */ unbindingWechat: ( params: { wechatUserId: string; @@ -230,6 +429,13 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 通过微信登录会话 ID 完成登录(Web 端扫码登录确认) + * @param wechatLoginId 微信登录会话 ID + * @param env Web 环境信息 + * @returns 返回登录 token + */ loginByWechat: ( params: { wechatLoginId: string; @@ -237,15 +443,37 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 从 URL 中提取网页信息(标题、发布时间、图片列表) + * @param url 网页 URL + * @returns 返回网页信息 + */ getInfoByUrl: (params: { url: string }) => Promise<{ title: string; publishDate: number | undefined; imageList: string[]; }>; + + /** + * 获取用户可用的修改密码方式 + * @param userId 用户 ID + * @returns 返回可用的修改方式列表(mobile-手机号,password-原密码) + */ getChangePasswordChannels: ( params: { userId: string }, context: BackendRuntimeContext ) => Promise; + + /** + * 修改用户密码 + * @param userId 用户 ID + * @param prevPassword 原密码(使用原密码验证时提供) + * @param mobile 手机号(使用手机号验证时提供) + * @param captcha 验证码(使用手机号验证时提供) + * @param newPassword 新密码 + * @returns 返回修改结果 + */ updateUserPassword: ( params: { userId: string; @@ -256,6 +484,15 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise<{ result: string; times?: number }>; + + /** + * 创建或获取会话(用于客服系统等场景) + * @param data 微信事件数据(可选) + * @param type 应用类型 + * @param entity 关联实体类型(可选) + * @param entityId 关联实体 ID(可选) + * @returns 返回会话 ID + */ createSession: ( params: { data?: WechatPublicEventData | WechatMpEventData; @@ -265,22 +502,47 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 上传素材到微信服务器 + * @param params 包含文件信息、应用 ID、素材类型等 + * @returns 返回微信 mediaId + */ uploadWechatMedia: ( params: any, context: BackendRuntimeContext ) => Promise<{ mediaId: string }>; + + /** + * 获取微信公众号当前使用的自定义菜单配置 + * @param applicationId 应用 ID + * @returns 返回当前菜单配置 + */ getCurrentMenu: ( params: { applicationId: string; }, context: BackendRuntimeContext ) => Promise; + + /** + * 获取微信公众号自定义菜单配置(包括默认和个性化菜单) + * @param applicationId 应用 ID + * @returns 返回菜单配置 + */ getMenu: ( params: { applicationId: string; }, context: BackendRuntimeContext ) => Promise; + + /** + * 创建微信公众号自定义菜单 + * @param applicationId 应用 ID + * @param menuConfig 菜单配置 + * @param id 菜单记录 ID + */ createMenu: ( params: { applicationId: string; @@ -289,6 +551,13 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 创建微信公众号个性化菜单(针对特定用户群体) + * @param applicationId 应用 ID + * @param menuConfig 菜单配置 + * @param id 菜单记录 ID + */ createConditionalMenu: ( params: { applicationId: string; @@ -297,6 +566,12 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 删除微信公众号个性化菜单 + * @param applicationId 应用 ID + * @param menuId 微信菜单 ID + */ deleteConditionalMenu: ( params: { applicationId: string; @@ -304,12 +579,26 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 删除微信公众号自定义菜单 + * @param applicationId 应用 ID + */ deleteMenu: ( params: { applicationId: string; }, context: BackendRuntimeContext ) => Promise; + + /** + * 批量获取微信公众号图文消息素材 + * @param applicationId 应用 ID + * @param offset 起始位置(可选) + * @param count 获取数量 + * @param noContent 是否不返回内容(0-返回,1-不返回) + * @returns 返回图文消息列表 + */ batchGetArticle: ( params: { applicationId: string; @@ -319,6 +608,13 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 获取微信公众号单个图文消息素材 + * @param applicationId 应用 ID + * @param articleId 图文消息 ID + * @returns 返回图文消息详情 + */ getArticle: ( params: { applicationId: string; @@ -326,6 +622,15 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 批量获取微信素材列表 + * @param applicationId 应用 ID + * @param type 素材类型(image-图片,voice-语音,video-视频,news-图文) + * @param offset 起始位置(可选) + * @param count 获取数量 + * @returns 返回素材列表 + */ batchGetMaterialList: ( params: { applicationId: string; @@ -335,6 +640,14 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 获取微信素材 + * @param applicationId 应用 ID + * @param mediaId 素材 ID + * @param isPermanent 是否为永久素材(默认获取临时素材) + * @returns 返回素材数据 + */ getMaterial: ( params: { applicationId: string; @@ -343,6 +656,12 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 删除微信永久素材 + * @param applicationId 应用 ID + * @param mediaId 素材 ID + */ deleteMaterial: ( params: { applicationId: string; @@ -350,6 +669,13 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 创建微信公众号用户标签 + * @param applicationId 应用 ID + * @param name 标签名称 + * @returns 返回创建结果 + */ createTag: ( params: { applicationId: string; @@ -357,12 +683,25 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 获取微信公众号所有用户标签 + * @param applicationId 应用 ID + * @returns 返回标签列表 + */ getTags: ( params: { applicationId: string; }, context: BackendRuntimeContext ) => Promise; + + /** + * 编辑微信公众号用户标签 + * @param applicationId 应用 ID + * @param id 微信标签 ID + * @param name 新标签名称 + */ editTag: ( params: { applicationId: string; @@ -371,6 +710,13 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 删除微信公众号用户标签 + * @param applicationId 应用 ID + * @param id 本地标签 ID + * @param wechatId 微信标签 ID + */ deleteTag: ( params: { applicationId: string; @@ -379,16 +725,33 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 同步微信公众号消息模板到本地 + * @param applicationId 应用 ID + * @returns 返回同步结果 + */ syncMessageTemplate: ( params: { applicationId: string; }, context: BackendRuntimeContext ) => Promise; + + /** + * 获取已注册的消息类型列表 + * @returns 返回消息类型数组 + */ getMessageType: ( params: {}, content: BackendRuntimeContext ) => Promise; + + /** + * 同步单个微信公众号用户标签到微信服务器 + * @param applicationId 应用 ID + * @param id 本地标签 ID + */ syncTag: ( params: { applicationId: string; @@ -396,12 +759,24 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 一键同步微信公众号用户标签(从微信服务器同步到本地) + * @param applicationId 应用 ID + */ oneKeySync: ( params: { applicationId: string; }, context: BackendRuntimeContext ) => Promise; + + /** + * 获取指定标签下的微信用户列表 + * @param applicationId 应用 ID + * @param tagId 微信标签 ID + * @returns 返回用户列表 + */ getTagUsers: ( params: { applicationId: string; @@ -409,6 +784,13 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 批量为用户打标签 + * @param applicationId 应用 ID + * @param openIdList 微信用户 openId 列表 + * @param tagId 微信标签 ID + */ batchtagging: ( params: { applicationId: string; @@ -417,6 +799,13 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 批量为用户取消标签 + * @param applicationId 应用 ID + * @param openIdList 微信用户 openId 列表 + * @param tagId 微信标签 ID + */ batchuntagging: ( params: { applicationId: string; @@ -425,6 +814,13 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 获取用户身上的标签列表 + * @param applicationId 应用 ID + * @param openId 微信用户 openId + * @returns 返回用户的标签 ID 列表 + */ getUserTags: ( params: { applicationId: string; @@ -432,6 +828,13 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 获取微信公众号用户列表 + * @param applicationId 应用 ID + * @param nextOpenId 下一个用户的 openId(用于分页) + * @returns 返回用户列表 + */ getUsers: ( params: { applicationId: string; @@ -439,6 +842,13 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 为单个用户设置标签列表 + * @param applicationId 应用 ID + * @param openId 微信用户 openId + * @param tagIdList 标签 ID 列表 + */ tagging: ( params: { applicationId: string; @@ -447,6 +857,12 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 从微信服务器同步用户标签到本地 + * @param applicationId 应用 ID + * @param openId 微信用户 openId + */ syncToLocale: ( params: { applicationId: string; @@ -454,6 +870,13 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 将本地用户标签同步到微信服务器 + * @param applicationId 应用 ID + * @param id 本地用户标签关联 ID + * @param openId 微信用户 openId + */ syncToWechat: ( params: { applicationId: string; @@ -462,6 +885,12 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 同步短信模板(从服务商同步到本地) + * @param systemId 系统 ID + * @param origin 短信服务商(如阿里云、腾讯云等) + */ syncSmsTemplate: ( params: { systemId: string; @@ -469,18 +898,97 @@ export type AspectDict = { }, context: BackendRuntimeContext ) => Promise; + + /** + * 获取应用的登录方式配置列表 + * @param applicationId 应用 ID + * @returns 返回登录方式配置列表 + */ getApplicationPassports: ( params: { applicationId: string; }, context: BackendRuntimeContext ) => Promise; + + /** + * 根据登录方式 ID 列表删除应用的登录方式配置 + * @param passportIds 登录方式 ID 列表 + */ removeApplicationPassportsByPIds: ( params: { passportIds: string[]; }, content: BackendRuntimeContext ) => Promise; + + /** + * 通过 OAuth 2.0 第三方登录 + * @param code OAuth 授权码 + * @param state 状态码(用于验证请求) + * @param env 环境信息 + * @returns 返回登录 token + */ + loginByOauth: ( + params: { + code: string; + state: string; + env: WebEnv | WechatMpEnv | NativeEnv; + }, + context: BackendRuntimeContext + ) => Promise; + + /** + * 创建 OAuth 登录/绑定状态码 + * @param providerId OAuth 提供商 ID + * @param userId 用户 ID(绑定时需要) + * @param type 操作类型(bind-绑定,login-登录) + * @returns 返回状态码 + */ + createOAuthState: ( + params: { + providerId: string; + userId?: string; + type: "bind" | "login"; + }, + context: BackendRuntimeContext + ) => Promise; + + /** + * OAuth 2.0 授权确认(用户同意或拒绝授权) + * @param response_type 响应类型(固定为 "code") + * @param client_id 客户端应用 ID + * @param redirect_uri 回调地址 + * @param scope 授权范围 + * @param state 状态码 + * @param action 用户操作(grant-同意,deny-拒绝) + * @returns 返回重定向 URL + */ + authorize: ( + params: { + response_type: string; + client_id: string; + redirect_uri: string; + scope: string; + state: string; + action: "grant" | "deny"; + }, + context: BackendRuntimeContext + ) => Promise<{ + redirectUri: string; + }> + + /** + * 获取 OAuth 客户端应用信息 + * @param client_id 客户端应用 ID + * @returns 返回客户端应用信息,不存在则返回 null + */ + getOAuthClientInfo: ( + params: { + client_id: string; + }, + context: BackendRuntimeContext + ) => Promise; }; export default AspectDict; diff --git a/src/aspects/index.ts b/src/aspects/index.ts index 9eb402762..b05fd230c 100644 --- a/src/aspects/index.ts +++ b/src/aspects/index.ts @@ -69,6 +69,7 @@ import { wechatMpJump, } from './wechatMpJump'; import { getApplicationPassports, removeApplicationPassportsByPIds } from './applicationPassport'; +import { authorize, createOAuthState, getOAuthClientInfo, loginByOauth } from './oauth'; const aspectDict = { bindByEmail, @@ -136,6 +137,11 @@ const aspectDict = { removeApplicationPassportsByPIds, verifyPassword, loginWebByMpToken, + // oauth + loginByOauth, + getOAuthClientInfo, + createOAuthState, + authorize, }; export default aspectDict; diff --git a/src/aspects/oauth.ts b/src/aspects/oauth.ts new file mode 100644 index 000000000..138f03e3f --- /dev/null +++ b/src/aspects/oauth.ts @@ -0,0 +1,483 @@ +import assert from "assert"; +import { BRC } from "../types/RuntimeCxt"; +import { EntityDict } from "../oak-app-domain"; +import { NativeEnv, OakUserException, WebEnv, WechatMpEnv } from "oak-domain/lib/types"; +import { generateNewIdAsync } from "oak-domain/lib/utils/uuid"; +import { loadTokenInfo, setUpTokenAndUser } from "./token"; +import { randomUUID } from "crypto"; +import { processUserInfo } from "../utils/oauth"; + +export async function loginByOauth(params: { + code: string; + state: string; + env: WebEnv | WechatMpEnv | NativeEnv; +}, context: BRC) { + const { code, state: stateCode, env } = params; + + const closeRootMode = context.openRootMode(); + const currentUserId = context.getCurrentUserId(true); + const applicationId = context.getApplicationId(); + const islogginedIn = !!currentUserId; + + assert(applicationId, '无法获取当前应用ID'); + assert(code, 'code 参数缺失'); + assert(stateCode, 'state 参数缺失'); + + // 验证 state 并获取 OAuth 配置 + const [state] = await context.select("oauthState", { + data: { + provider: { + type: 1, + clientId: 1, + redirectUri: 1, + clientSecret: 1, + tokenEndpoint: 1, + userInfoEndpoint: 1, + ableState: 1, + autoRegister: 1, + }, + usedAt: 1, + }, + filter: { + state: stateCode, + }, + }, {}) + + assert(state, '无效的 state 参数'); + assert(state.provider?.ableState && state.provider?.ableState === 'enabled', '该 OAuth 提供商已被禁用'); + // 如果已经使用 + if (state.usedAt) { + throw new OakUserException('该授权请求已被使用,请重新发起授权请求'); + } + + // 更新为使用过 + await context.operate("oauthState", { + id: await generateNewIdAsync(), + action: 'update', + data: { + usedAt: Date.now(), + }, + filter: { + id: state.id, + } + }, {}); + + // 使用 code 换取 access_token 并获取用户信息 + const { oauthUserInfo, accessToken, refreshToken, accessTokenExp, refreshTokenExp } = await fetchOAuthUserInfo( + code, + state.provider, + ); + + const [existingOAuthUser] = await context.select("oauthUser", { + data: { + id: 1, + userId: 1, + providerUserId: 1, + user: { + id: 1, + userState: 1, + refId: 1, + ref: { + id: 1, + userState: 1, + } + } + }, + filter: { + providerUserId: oauthUserInfo.providerUserId, + providerConfigId: state.providerId!, + } + }, {}) + + // 已登录的情况 + if (islogginedIn) { + + // 检查当前用户是否已绑定此提供商 + const [currentUserBinding] = await context.select("oauthUser", { + data: { + id: 1, + }, + filter: { + userId: currentUserId, + providerConfigId: state.providerId!, + } + }, {}); + + if (currentUserBinding) { + throw new OakUserException('当前用户已绑定该 OAuth 平台账号'); + } + + if (existingOAuthUser) { + throw new OakUserException('该 OAuth 账号已被其他用户绑定'); + } + + console.log("绑定 OAuth 账号到当前用户:", currentUserId, oauthUserInfo.providerUserId); + + // 创建绑定关系 + await context.operate("oauthUser", { + id: await generateNewIdAsync(), + action: 'create', + data: { + id: await generateNewIdAsync(), + userId: currentUserId, + providerConfigId: state.providerId, + providerUserId: oauthUserInfo.providerUserId, + rawUserInfo: oauthUserInfo.rawData, + accessToken, + accessExpiresAt: accessTokenExp, + refreshToken, + refreshExpiresAt: refreshTokenExp, + applicationId, + stateId: state.id, + } + }, {}); + + // 返回当前 token + const tokenValue = context.getTokenValue()!; + await loadTokenInfo(tokenValue, context); + closeRootMode(); + return tokenValue; + + // 未登录,OAuth账号已存在,直接登录 + } else if (existingOAuthUser) { + + console.log("使用已绑定的 OAuth 账号登录:", existingOAuthUser.id); + + const { user } = existingOAuthUser; + const targetUser = user?.userState === 'merged' ? user.ref : user; + + const tokenValue = await setUpTokenAndUser( + env, + context, + 'oauthUser', + existingOAuthUser.id, // 使用已存在的 oauthUser ID + undefined, + targetUser // 关联的用户 + ); + + // 更新登录信息 + await context.operate("oauthUser", { + id: await generateNewIdAsync(), + action: 'update', + data: { + rawUserInfo: oauthUserInfo.rawData, + accessToken, + accessExpiresAt: accessTokenExp, + refreshToken, + refreshExpiresAt: refreshTokenExp, + applicationId, + stateId: state.id, + }, + filter: { + id: existingOAuthUser.id, + } + }, {}); + + await loadTokenInfo(tokenValue, context); + closeRootMode(); + return tokenValue; + + } + // 未登录,OAuth账号不存在,创建新用户 + else { + + if (!state.provider.autoRegister) { + throw new OakUserException('您还没有账号,请先注册一个账号'); + } + + console.log("使用未绑定的 OAuth 账号登录:", oauthUserInfo.providerUserId); + + const newUserId = await generateNewIdAsync(); + const oauthUserCreateData: EntityDict['oauthUser']['CreateOperationData'] = { + id: newUserId, + providerConfigId: state.providerId, + providerUserId: oauthUserInfo.providerUserId, + rawUserInfo: oauthUserInfo.rawData, + accessToken, + accessExpiresAt: accessTokenExp, + refreshToken, + refreshExpiresAt: refreshTokenExp, + applicationId, + stateId: state.id, + loadState: 'unload' + }; + + // 不传 user 参数,会自动创建新用户 + const tokenValue = await setUpTokenAndUser( + env, + context, + 'oauthUser', + undefined, + oauthUserCreateData, // 创建新的 oauthUser + undefined // 不传 user,自动创建新用户 + ); + + await context.operate("oauthUser", { + id: await generateNewIdAsync(), + action: 'loadUserInfo', + data: {}, + filter: { + id: newUserId, + } + }, {}); + + await loadTokenInfo(tokenValue, context); + closeRootMode(); + return tokenValue; + } +} +export async function getOAuthClientInfo(params: { + client_id: string; +}, context: BRC) { + const { client_id } = params; + const closeRootMode = context.openRootMode(); + const systemId = context.getSystemId(); + const [oauthApp] = await context.select("oauthApplication", { + data: { + id: 1, + name: 1, + redirectUris: 1, + description: 1, + logo: 1, + isConfidential: 1, + }, + filter: { + id: client_id, + systemId: systemId, + ableState: "enabled", + } + }, {}) + + if (!oauthApp) { + throw new OakUserException('未经授权的客户端应用'); + } + + closeRootMode(); + return oauthApp; +} +export async function createOAuthState(params: { + providerId: string; + userId?: string; + type: 'login' | 'bind'; +}, context: BRC) { + const { providerId, userId, type } = params; + const closeRootMode = context.openRootMode(); + const generateCode = () => { + return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); + } + + const state = generateCode(); + + await context.operate("oauthState", { + id: await generateNewIdAsync(), + action: 'create', + data: { + id: await generateNewIdAsync(), + providerId, + userId, + type, + state + } + }, {}) + + closeRootMode(); + return state; +} +export async function authorize(params: { + response_type: string; + client_id: string; + redirect_uri: string; + scope?: string; + state?: string; + action: 'grant' | 'deny'; +}, context: BRC) { + const { response_type, client_id, redirect_uri, scope, state, action } = params; + + if (response_type !== 'code') { + throw new OakUserException('不支持的 response_type 类型'); + } + + const closeRootMode = context.openRootMode(); + const systemId = context.getSystemId(); + + const [oauthApp] = await context.select("oauthApplication", { + data: { + id: 1, + redirectUris: 1, + isConfidential: 1, + }, + filter: { + id: client_id, + systemId: systemId, + } + }, {}) + + if (!oauthApp) { + throw new OakUserException('未经授权的客户端应用'); + } + + // 创建授权记录 + const recordId = await generateNewIdAsync(); + await context.operate("oauthUserAuthorization", { + id: await generateNewIdAsync(), + action: 'create', + data: { + id: recordId, + userId: context.getCurrentUserId()!, + applicationId: oauthApp.id, + usageState: action === 'grant' ? 'granted' : 'denied', + authorizedAt: Date.now(), + } + }, {}) + + if (action === 'deny') { + const params = new URLSearchParams(); + params.set('error', 'access_denied'); + params.set('error_description', '用户拒绝了授权请求'); + + if (state) { + params.set('state', state); + } + closeRootMode(); + return { + redirectUri: `${redirect_uri}?${params.toString()}`, + } + } + + if (action === 'grant') { + + // 检查redirectUri 是否在注册的列表中 + if (!oauthApp.redirectUris?.includes(redirect_uri)) { + console.log('不合法的重定向 URI:', redirect_uri, oauthApp.redirectUris); + throw new OakUserException('重定向 URI 不合法'); + } + + const code = randomUUID(); + const codeId = await generateNewIdAsync(); + // 存储授权码 + await context.operate("oauthAuthorizationCode", { + id: await generateNewIdAsync(), + action: 'create', + data: { + id: codeId, + code, + redirectUri: redirect_uri, + oauthAppId: oauthApp.id, + applicationId: context.getApplicationId()!, + userId: context.getCurrentUserId()!, + scope: [scope || ""], + expiresAt: Date.now() + 10 * 60 * 1000, // 10分钟后过期 + } + }, {}) + + // 更新记录 + await context.operate("oauthUserAuthorization", { + id: await generateNewIdAsync(), + action: 'update', + data: { + codeId: codeId, + }, + filter: { + id: recordId, + } + }, {}) + + const params = new URLSearchParams(); + params.set('code', code); + if (state) { + params.set('state', state); + } + closeRootMode(); + return { + redirectUri: `${redirect_uri}?${params.toString()}`, + } + } + + closeRootMode(); + + throw new Error('unknown action'); +} + + +const fetchOAuthUserInfo = async ( + code: string, + providerConfig: EntityDict['oauthProvider']['Schema'], +): Promise<{ + oauthUserInfo: { + providerUserId: string; + rawData: any; + }; + accessToken: string; + refreshToken?: string; + accessTokenExp?: number; + refreshTokenExp?: number; +}> => { + // 1. 使用 code 换取 access_token + const tokenResponse = await fetch(providerConfig.tokenEndpoint!, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: code, + client_id: providerConfig.clientId!, + client_secret: providerConfig.clientSecret!, + redirect_uri: providerConfig.redirectUri!, + }), + }); + + if (!tokenResponse.ok) { + const errorjson = await tokenResponse.json(); + if (errorjson.error == "unauthorized_client") { + throw new OakUserException(`授权校验已过期,请重新发起授权请求`); + } else if (errorjson.error == "invalid_grant") { + throw new OakUserException(`授权码无效或已过期,请重新发起授权请求`); + } else if (errorjson.error) { + throw new OakUserException(`获取访问令牌失败: ${errorjson.error_description || errorjson.error}`); + } + throw new OakUserException(`获取访问令牌失败: ${tokenResponse.statusText}`); + } + + const tokenData = await tokenResponse.json(); + const accessToken = tokenData.access_token; + const refreshToken = tokenData.refresh_token; + const accessTokenExp = tokenData.expires_in ? Date.now() + tokenData.expires_in * 1000 : undefined; + const refreshTokenExp = tokenData.refresh_expires_in ? Date.now() + tokenData.refresh_expires_in * 1000 : undefined; + const tokenType = tokenData.token_type; + + assert(tokenType && tokenType.toLowerCase() === 'bearer', '不支持的令牌类型'); + + // 2. 使用 access_token 获取用户信息 + const userInfoResponse = await fetch(providerConfig.userInfoEndpoint!, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${accessToken}`, + }, + }); + + if (!userInfoResponse.ok) { + throw new OakUserException(`获取用户信息失败: ${userInfoResponse.statusText}`); + } + + + const userInfoData = await userInfoResponse.json(); + + // TODO: 用户信息中获取唯一标识,通过注入解决: utils/oauth/index.ts + const { id: providerUserId } = await processUserInfo(providerConfig.type!, userInfoData) + + if (!providerUserId) { + throw new OakUserException('用户信息中缺少唯一标识符'); + } + + return { + oauthUserInfo: { + providerUserId, + rawData: userInfoData, + }, + accessToken, + refreshToken, + accessTokenExp, + refreshTokenExp, + }; +}