diff --git a/apps/web-antd/src/api/core/sso.ts b/apps/web-antd/src/api/core/sso.ts new file mode 100644 index 000000000..e0dfb0881 --- /dev/null +++ b/apps/web-antd/src/api/core/sso.ts @@ -0,0 +1,38 @@ +import { requestClient } from '#/api/request'; + +/** + * SSO 单点登录相关接口(对接 /system/sso/callback) + * 用于从业务平台跳转过来时,用 code 换 IoT 平台的 access_token + */ +export namespace SsoApi { + export interface SsoCallbackResult { + access_token: string; + refresh_token: string; + expires_in: number; + token_type: string; + } +} + +/** + * 用 SSO code 换 access_token + * client_secret 不在前端,由后端从配置读取 + * + * @param clientId 当前系统的 client_id,IoT 平台固定为 iot(原 iot-client 已改名) + * @param code 授权码(从 URL 读取) + * @param redirectUri 回调地址(必须与发起授权时一致) + * @param state 发起授权时的随机串,从 URL 读出原样回传(CSRF 防护 + 校验一致性) + */ +export function ssoCallback( + clientId: string, + code: string, + redirectUri: string, + state?: string, +) { + // 放 body 而非 query,避免 code 落入浏览器历史 / nginx access log + return requestClient.post('/system/sso/callback', { + clientId, + code, + redirectUri, + state, + }); +} diff --git a/apps/web-antd/src/api/request.ts b/apps/web-antd/src/api/request.ts index 2568def87..5bf60dc6c 100644 --- a/apps/web-antd/src/api/request.ts +++ b/apps/web-antd/src/api/request.ts @@ -16,6 +16,7 @@ import { createApiEncrypt } from '@vben/utils'; import { message } from 'ant-design-vue'; +import { IOT_CLIENT_ID } from '#/constants/sso'; import { useAuthStore } from '#/store'; import { refreshTokenApi } from './core'; @@ -23,6 +24,12 @@ import { refreshTokenApi } from './core'; const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD); const tenantEnable = isTenantEnable(); const apiEncrypt = createApiEncrypt(import.meta.env); +/** + * 前端身份(OAuth2 client_id):后端 /system/auth/login & /refresh-token + * 按此值生成 token,并按 platform 过滤菜单(iot → platform=sys)。 + * 走环境变量是为了让多个 apps 壳(antd/ele/naive/tdesign)各自声明。 + */ +const clientId = import.meta.env.VITE_APP_CLIENT_ID || IOT_CLIENT_ID; function createRequestClient(baseURL: string, options?: RequestClientOptions) { const client = new RequestClient({ @@ -86,6 +93,13 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) { config.headers['visit-tenant-id'] = tenantEnable ? accessStore.visitTenantId : undefined; + // 添加项目编号(多项目切换) + // 仅在有值时设置,避免 axios 把 null 序列化为字符串 "null" 造成后端误判 + if (accessStore.projectId != null) { + config.headers['project-id'] = accessStore.projectId; + } + // 声明前端身份(见文件顶部 clientId 注释) + config.headers['X-Client-Id'] = clientId; // 是否 API 加密 if ((config.headers || {}).isEncrypt) { @@ -182,6 +196,10 @@ baseRequestClient.addRequestInterceptor({ config.headers['visit-tenant-id'] = tenantEnable ? accessStore.visitTenantId : undefined; + if (accessStore.projectId != null) { + config.headers['project-id'] = accessStore.projectId; + } + config.headers['X-Client-Id'] = clientId; return config; }, }); diff --git a/apps/web-antd/src/api/system/project/index.ts b/apps/web-antd/src/api/system/project/index.ts new file mode 100644 index 000000000..c791dfd1b --- /dev/null +++ b/apps/web-antd/src/api/system/project/index.ts @@ -0,0 +1,21 @@ +import { requestClient } from '#/api/request'; + +export namespace SystemProjectApi { + export interface Project { + id?: number; + name: string; + code: string; + status: number; + contactName?: string; + contactMobile?: string; + address?: string; + remark?: string; + createTime?: Date; + } +} + +export function getSimpleProjectList() { + return requestClient.get( + '/system/project/simple-list', + ); +} diff --git a/apps/web-antd/src/constants/sso.ts b/apps/web-antd/src/constants/sso.ts new file mode 100644 index 000000000..fe14e2130 --- /dev/null +++ b/apps/web-antd/src/constants/sso.ts @@ -0,0 +1,34 @@ +/** + * SSO / OAuth2 相关常量 + * + * 集中维护 client_id、state 存储 key 等跨文件复用的字符串,避免魔法字符串散落。 + */ + +/** 本平台(物联运维)在芋道后端登记的 OAuth2 客户端编号 */ +export const IOT_CLIENT_ID = 'iot'; + +/** 业务平台在芋道后端登记的 OAuth2 客户端编号 */ +export const BIZ_CLIENT_ID = 'biz'; + +/** 跳业务平台发起 OAuth2 授权时,state 写入 sessionStorage 的 key */ +export const SSO_STATE_STORAGE_KEY = 'sso:biz:state'; + +/** 本平台自身 SSO 回调页面路径 */ +export const SSO_CALLBACK_PATH = '/sso-callback'; + +/** + * 生成一个密码学安全的随机 state。 + * 首选 crypto.randomUUID;低版本浏览器回退 getRandomValues。 + */ +export function generateOauthState(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + if (typeof crypto !== 'undefined' && crypto.getRandomValues) { + const arr = new Uint8Array(16); + crypto.getRandomValues(arr); + return Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join(''); + } + // 极端降级:不会加密但比 Math.random 多一层时间戳 + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +} diff --git a/apps/web-antd/src/layouts/basic.vue b/apps/web-antd/src/layouts/basic.vue index e8715a895..5eaadc18d 100644 --- a/apps/web-antd/src/layouts/basic.vue +++ b/apps/web-antd/src/layouts/basic.vue @@ -1,14 +1,13 @@ + + diff --git a/packages/effects/layouts/src/widgets/index.ts b/packages/effects/layouts/src/widgets/index.ts index 665cdb9e9..7d6eccdb6 100644 --- a/packages/effects/layouts/src/widgets/index.ts +++ b/packages/effects/layouts/src/widgets/index.ts @@ -8,6 +8,7 @@ export { default as AuthenticationLayoutToggle } from './layout-toggle.vue'; export * from './lock-screen'; export * from './notification'; export * from './preferences'; +export * from './project-dropdown'; export * from './tenant-dropdown'; export * from './theme-toggle'; export * from './timezone'; diff --git a/packages/effects/layouts/src/widgets/project-dropdown/index.ts b/packages/effects/layouts/src/widgets/project-dropdown/index.ts new file mode 100644 index 000000000..8ae16ff57 --- /dev/null +++ b/packages/effects/layouts/src/widgets/project-dropdown/index.ts @@ -0,0 +1 @@ +export { default as ProjectDropdown } from './project-dropdown.vue'; diff --git a/packages/effects/layouts/src/widgets/project-dropdown/project-dropdown.vue b/packages/effects/layouts/src/widgets/project-dropdown/project-dropdown.vue new file mode 100644 index 000000000..89fc7c4ae --- /dev/null +++ b/packages/effects/layouts/src/widgets/project-dropdown/project-dropdown.vue @@ -0,0 +1,84 @@ + + diff --git a/packages/stores/src/modules/access.ts b/packages/stores/src/modules/access.ts index 785fffdff..c4ab92eff 100644 --- a/packages/stores/src/modules/access.ts +++ b/packages/stores/src/modules/access.ts @@ -39,6 +39,10 @@ interface AccessState { * 登录是否过期 */ loginExpired: boolean; + /** + * 当前项目编号 + */ + projectId: null | number; /** * 登录 accessToken */ @@ -102,6 +106,9 @@ export const useAccessStore = defineStore('core-access', { setRefreshToken(token: AccessToken) { this.refreshToken = token; }, + setProjectId(projectId: null | number) { + this.projectId = projectId; + }, setTenantId(tenantId: null | number) { this.tenantId = tenantId; }, @@ -121,6 +128,7 @@ export const useAccessStore = defineStore('core-access', { 'accessCodes', 'tenantId', 'visitTenantId', + 'projectId', 'isLockScreen', 'lockScreenPassword', ], @@ -134,6 +142,7 @@ export const useAccessStore = defineStore('core-access', { isLockScreen: false, lockScreenPassword: undefined, loginExpired: false, + projectId: null, refreshToken: null, tenantId: null, visitTenantId: null,