diff --git a/packages/app/client/src/plugins/oidc.ts b/packages/app/client/src/plugins/oidc.ts new file mode 100644 index 000000000..b0a541801 --- /dev/null +++ b/packages/app/client/src/plugins/oidc.ts @@ -0,0 +1 @@ +export { default } from '@nocobase/plugin-oidc/client'; \ No newline at end of file diff --git a/packages/core/client/src/application/Application.tsx b/packages/core/client/src/application/Application.tsx index 7469f0bd0..b0b8e0720 100644 --- a/packages/core/client/src/application/Application.tsx +++ b/packages/core/client/src/application/Application.tsx @@ -18,18 +18,19 @@ import { RemoteRouteSwitchProvider, RouteSchemaComponent, RouteSwitch, - useRoutes + useRoutes, } from '../route-switch'; import { AntdSchemaComponentProvider, DesignableSwitch, MenuItemInitializers, - SchemaComponentProvider + SchemaComponentProvider, } from '../schema-component'; import { SchemaInitializerProvider } from '../schema-initializer'; import { BlockTemplateDetails, BlockTemplatePage, SchemaTemplateShortcut } from '../schema-templates'; import { SystemSettingsProvider, SystemSettingsShortcut } from '../system-settings'; import { SigninPage, SignupPage } from '../user'; +import { SigninPageExtensionProvider } from '../user/SigninPageExtension'; import { compose } from './compose'; export interface ApplicationOptions { @@ -112,6 +113,7 @@ export class Application { }); this.use(BlockSchemaComponentProvider); this.use(AntdSchemaComponentProvider); + this.use(SigninPageExtensionProvider); this.use(ACLProvider); this.use(RemoteDocumentTitleProvider); this.use(PMProvider); diff --git a/packages/core/client/src/user/SigninPage.tsx b/packages/core/client/src/user/SigninPage.tsx index bc79ea471..6060985c4 100644 --- a/packages/core/client/src/user/SigninPage.tsx +++ b/packages/core/client/src/user/SigninPage.tsx @@ -1,9 +1,11 @@ import { ISchema, useForm } from '@formily/react'; -import { Tabs } from 'antd'; -import React, { useCallback } from 'react'; +import { Space, Tabs } from 'antd'; +import React, { useCallback, useContext } from 'react'; +import { css } from '@emotion/css'; import { useTranslation } from 'react-i18next'; import { Link, useHistory, useLocation } from 'react-router-dom'; import { SchemaComponent, useAPIClient, useCurrentDocumentTitle, useSystemSettings } from '..'; +import { useSigninPageExtension } from './SigninPageExtension'; import VerificationCode from './VerificationCode'; const passwordForm: ISchema = { @@ -47,7 +49,7 @@ const passwordForm: ISchema = { }, }; -function useRedirect(next = '/admin') { +export function useRedirect(next = '/admin') { const location = useLocation(); const history = useHistory(); const redirect = location?.['query']?.redirect; @@ -130,10 +132,16 @@ export const SigninPage = (props: SigninPageProps) => { const { t } = useTranslation(); useCurrentDocumentTitle('Signin'); const ctx = useSystemSettings(); + const signinExtension = useSigninPageExtension(); const { allowSignUp, smsAuthEnabled } = ctx?.data?.data || {}; const { schema, components, scope } = props; return ( -
+ {smsAuthEnabled ? ( @@ -157,11 +165,12 @@ export const SigninPage = (props: SigninPageProps) => { schema={schema || passwordForm} /> )} +
{signinExtension}
{allowSignUp && (
{t('Create an account')}
)} -
+ ); }; diff --git a/packages/core/client/src/user/SigninPageExtension.tsx b/packages/core/client/src/user/SigninPageExtension.tsx new file mode 100644 index 000000000..7a444703c --- /dev/null +++ b/packages/core/client/src/user/SigninPageExtension.tsx @@ -0,0 +1,24 @@ +import React, { createContext, FunctionComponent, useContext } from 'react'; + +export interface SigninPageExtensionContextValue { + components: FunctionComponent[]; +} + +export const SigninPageExtensionContext = createContext({ components: [] }); + +export const useSigninPageExtension = () => { + const { components } = useContext(SigninPageExtensionContext); + return components.map((component, index) => React.createElement(component, { key: index })); +}; + +export const SigninPageExtensionProvider = (props: { component: FunctionComponent; children: JSX.Element }) => { + const { components } = useContext(SigninPageExtensionContext); + + const list = props.component ? [...components, props.component] : components; + + return ( + + {props.children} + + ); +}; diff --git a/packages/core/client/src/user/index.ts b/packages/core/client/src/user/index.ts index 6577aa861..25ff5c54c 100644 --- a/packages/core/client/src/user/index.ts +++ b/packages/core/client/src/user/index.ts @@ -2,4 +2,4 @@ export * from './CurrentUser'; export * from './CurrentUserProvider'; export * from './SigninPage'; export * from './SignupPage'; - +export * from './SigninPageExtension'; diff --git a/packages/core/server/src/plugin-manager/PluginManager.ts b/packages/core/server/src/plugin-manager/PluginManager.ts index 491ecfa66..5f60b0da6 100644 --- a/packages/core/server/src/plugin-manager/PluginManager.ts +++ b/packages/core/server/src/plugin-manager/PluginManager.ts @@ -66,6 +66,7 @@ export class PluginManager { if (options?.method !== 'install' || options.reload) { await this.repository.load(); } + this.app.acl.allow('applicationPlugins', 'list'); }); this.addStaticMultiple(options.plugins); } @@ -329,7 +330,9 @@ export class PluginManager { } static getPluginPkgPrefix() { - return (process.env.PLUGIN_PACKAGE_PREFIX || '@nocobase/plugin-,@nocobase/preset-,@nocobase/plugin-pro-').split(','); + return (process.env.PLUGIN_PACKAGE_PREFIX || '@nocobase/plugin-,@nocobase/preset-,@nocobase/plugin-pro-').split( + ',', + ); } static async findPackage(name: string) { diff --git a/packages/plugins/oidc/client.d.ts b/packages/plugins/oidc/client.d.ts new file mode 100755 index 000000000..765db9222 --- /dev/null +++ b/packages/plugins/oidc/client.d.ts @@ -0,0 +1,4 @@ +// @ts-nocheck +export * from './lib/client'; +export { default } from './lib/client'; + diff --git a/packages/plugins/oidc/client.js b/packages/plugins/oidc/client.js new file mode 100755 index 000000000..238820257 --- /dev/null +++ b/packages/plugins/oidc/client.js @@ -0,0 +1,30 @@ +"use strict"; + +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } + +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } + +var _index = _interopRequireWildcard(require("./lib/client")); + +Object.defineProperty(exports, "__esModule", { + value: true +}); +var _exportNames = {}; +Object.defineProperty(exports, "default", { + enumerable: true, + get: function get() { + return _index.default; + } +}); + +Object.keys(_index).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _index[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function get() { + return _index[key]; + } + }); +}); diff --git a/packages/plugins/oidc/package.json b/packages/plugins/oidc/package.json new file mode 100644 index 000000000..7636f9dc6 --- /dev/null +++ b/packages/plugins/oidc/package.json @@ -0,0 +1,12 @@ +{ + "name": "@nocobase/plugin-oidc", + "version": "0.8.0-alpha.13", + "main": "lib/server/index.js", + "devDependencies": { + "@nocobase/server": "0.8.0-alpha.13", + "@nocobase/test": "0.8.0-alpha.13" + }, + "dependencies": { + "openid-client": "^5.3.0" + } +} diff --git a/packages/plugins/oidc/server.d.ts b/packages/plugins/oidc/server.d.ts new file mode 100755 index 000000000..e70edb928 --- /dev/null +++ b/packages/plugins/oidc/server.d.ts @@ -0,0 +1,4 @@ +// @ts-nocheck +export * from './lib/server'; +export { default } from './lib/server'; + diff --git a/packages/plugins/oidc/server.js b/packages/plugins/oidc/server.js new file mode 100755 index 000000000..d06a7eb92 --- /dev/null +++ b/packages/plugins/oidc/server.js @@ -0,0 +1,30 @@ +"use strict"; + +function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } + +function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } + +var _index = _interopRequireWildcard(require("./lib/server")); + +Object.defineProperty(exports, "__esModule", { + value: true +}); +var _exportNames = {}; +Object.defineProperty(exports, "default", { + enumerable: true, + get: function get() { + return _index.default; + } +}); + +Object.keys(_index).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _index[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function get() { + return _index[key]; + } + }); +}); diff --git a/packages/plugins/oidc/src/client/OIDCList.tsx b/packages/plugins/oidc/src/client/OIDCList.tsx new file mode 100644 index 000000000..24b0d7125 --- /dev/null +++ b/packages/plugins/oidc/src/client/OIDCList.tsx @@ -0,0 +1,101 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { Button, Space } from 'antd'; +import { LoginOutlined } from '@ant-design/icons'; +import { useMemoizedFn } from 'ahooks'; +import { css } from '@emotion/css'; +import { useAPIClient, useRedirect } from '@nocobase/client'; + +export interface OIDCProvider { + clientId: string; + title: string; + authorizeUrl: string; +} + +export const OIDCList = () => { + const [list, setList] = useState([]); + const [windowHandler, setWindowHandler] = useState(); + const api = useAPIClient(); + const redirect = useRedirect(); + + const getOidcList = async () => { + const { data: pluginsRes } = await api.request({ + url: 'app:getPlugins', + }); + if (!(pluginsRes.data as string[]).includes('oidc')) return; + const { data: providersRes } = await api.request({ + url: 'oidcProviders:list', + params: { + filter: { + enabled: true, + }, + }, + }); + setList(providersRes.data); + }; + + /** + * 打开登录弹出框 + */ + const handleOpen = useMemoizedFn(async (item: OIDCProvider) => { + const response = await api.request({ + method: 'post', + url: 'oidc:getAuthUrl', + data: { + clientId: item.clientId, + }, + }); + + const authUrl = response?.data?.data; + const { width, height } = screen; + + const win = window.open( + authUrl, + '_blank', + `width=800,height=600,left=${(width - 800) / 2},top=${ + (height - 600) / 2 + },toolbar=no,menubar=no,location=no,status=no`, + ); + + setWindowHandler(win); + }); + + /** + * 从弹出窗口,发消息回来进行登录 + */ + const handleOIDCLogin = useMemoizedFn(async (event: MessageEvent) => { + await api.auth.signIn(event.data, 'oidc'); + windowHandler.close(); + setWindowHandler(undefined); + redirect(); + }); + + /** + * 监听弹出窗口的消息 + */ + useEffect(() => { + if (!windowHandler) return; + window.addEventListener('message', handleOIDCLogin); + return () => { + window.removeEventListener('message', handleOIDCLogin); + }; + }, [windowHandler]); + + useEffect(() => { + getOidcList(); + }, []); + + return ( + + {list.map((item) => ( + + ))} + + ); +}; diff --git a/packages/plugins/oidc/src/client/OIDCPanel.tsx b/packages/plugins/oidc/src/client/OIDCPanel.tsx new file mode 100644 index 000000000..9d3852a11 --- /dev/null +++ b/packages/plugins/oidc/src/client/OIDCPanel.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { uid } from '@formily/shared'; +import { SchemaComponent, useRecord } from '@nocobase/client'; +import { Card } from 'antd'; +import { oidcSchema } from './schemas/oidc'; +import { RedirectURLInput } from './RedirectURLInput'; +import { useOidcTranslation } from './locale'; + +const schema = { + type: 'object', + properties: { + [uid()]: oidcSchema, + }, +}; + +export const OIDCPanel = () => { + const { t } = useOidcTranslation(); + return ( + + + + ); +}; diff --git a/packages/plugins/oidc/src/client/RedirectURLInput.tsx b/packages/plugins/oidc/src/client/RedirectURLInput.tsx new file mode 100644 index 000000000..f756697c9 --- /dev/null +++ b/packages/plugins/oidc/src/client/RedirectURLInput.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { FormLayout } from '@formily/antd'; +import { Field } from '@formily/core'; +import { observer, useField, useForm } from '@formily/react'; +import { useEffect } from 'react'; +import { Input, useRecord } from '@nocobase/client'; + +export const RedirectURLInput = observer(() => { + const form = useForm(); + const field = useField(); + const record = useRecord(); + + const clientId = form.values.clientId ?? record.clientId; + + useEffect(() => { + const { protocol, host } = window.location; + field.setValue(`${protocol}//${host}/api/oidc:redirect?clientId=${clientId}`); + }, [clientId]); + + return ( +
+ + + +
+ ); +}); diff --git a/packages/plugins/oidc/src/client/index.tsx b/packages/plugins/oidc/src/client/index.tsx new file mode 100644 index 000000000..e87e488a1 --- /dev/null +++ b/packages/plugins/oidc/src/client/index.tsx @@ -0,0 +1,38 @@ +import { PluginManagerContext, SettingsCenterProvider, SigninPageExtensionProvider } from '@nocobase/client'; +import React, { useContext } from 'react'; +import { useOidcTranslation } from './locale'; +import { OIDCList } from './OIDCList'; +import { OIDCPanel } from './OIDCPanel'; + +export default function (props) { + const { t } = useOidcTranslation(); + const ctx = useContext(PluginManagerContext); + return ( + + + + {props.children} + + + + ); +} diff --git a/packages/plugins/oidc/src/client/locale/en-US.ts b/packages/plugins/oidc/src/client/locale/en-US.ts new file mode 100644 index 000000000..fbd242d07 --- /dev/null +++ b/packages/plugins/oidc/src/client/locale/en-US.ts @@ -0,0 +1,20 @@ +export default { + Enable: 'Enable', + Issuer: 'Issuer', + 'OIDC manager': 'OIDC manager', + 'OIDC Providers': 'OIDC Providers', + 'Provider name': 'Name', + 'Client id': 'Client id', + 'Client secret': 'Client secret', + 'Openid configuration': 'Openid configuration', + 'Authorization endpoint': 'Authorization endpoint', + 'Access token endpoint': 'Access token endpoint', + 'JWKS endpoint': 'JWKS endpoint', + 'Userinfo endpoint': 'Userinfo endpoint', + 'Redirect url': 'Redirect url', + 'Logout endpoint': 'Logout endpoint', + 'Id token sign alg': 'Id token sign alg', + 'Add provider': 'Add provider', + 'Edit provider': 'Edit provider', + 'Delete provider': 'Delete provider', +}; diff --git a/packages/plugins/oidc/src/client/locale/index.ts b/packages/plugins/oidc/src/client/locale/index.ts new file mode 100644 index 000000000..55b12cbd0 --- /dev/null +++ b/packages/plugins/oidc/src/client/locale/index.ts @@ -0,0 +1,24 @@ +import { useTranslation } from 'react-i18next'; +import { i18n } from '@nocobase/client'; + +import zhCN from './zh-CN'; +import enUS from './en-US'; +import jaJP from './ja-JP'; +import ruRU from './ru-RU'; +import trTR from './tr-TR'; + +export const NAMESPACE = 'workflow'; + +i18n.addResources('zh-CN', NAMESPACE, zhCN); +i18n.addResources('en-US', NAMESPACE, enUS); +i18n.addResources('ja-JP', NAMESPACE, jaJP); +i18n.addResources('ru-RU', NAMESPACE, ruRU); +i18n.addResources('tr-TR', NAMESPACE, trTR); + +export function lang(key: string) { + return i18n.t(key, { ns: NAMESPACE }); +} + +export function useOidcTranslation() { + return useTranslation(NAMESPACE); +} diff --git a/packages/plugins/oidc/src/client/locale/ja-JP.ts b/packages/plugins/oidc/src/client/locale/ja-JP.ts new file mode 100644 index 000000000..ff8b4c563 --- /dev/null +++ b/packages/plugins/oidc/src/client/locale/ja-JP.ts @@ -0,0 +1 @@ +export default {}; diff --git a/packages/plugins/oidc/src/client/locale/ru-RU.ts b/packages/plugins/oidc/src/client/locale/ru-RU.ts new file mode 100644 index 000000000..ff8b4c563 --- /dev/null +++ b/packages/plugins/oidc/src/client/locale/ru-RU.ts @@ -0,0 +1 @@ +export default {}; diff --git a/packages/plugins/oidc/src/client/locale/tr-TR.ts b/packages/plugins/oidc/src/client/locale/tr-TR.ts new file mode 100644 index 000000000..ff8b4c563 --- /dev/null +++ b/packages/plugins/oidc/src/client/locale/tr-TR.ts @@ -0,0 +1 @@ +export default {}; diff --git a/packages/plugins/oidc/src/client/locale/zh-CN.ts b/packages/plugins/oidc/src/client/locale/zh-CN.ts new file mode 100644 index 000000000..1b71ead81 --- /dev/null +++ b/packages/plugins/oidc/src/client/locale/zh-CN.ts @@ -0,0 +1,23 @@ +export default { + Issuer: 'Issuer', + Enable: '启用', + Actions: '操作', + Delete: '删除', + Edit: '编辑', + 'OIDC manager': 'OIDC 管理', + 'OIDC Providers': 'OIDC 身份提供者', + 'Provider name': '名称', + 'Client id': '客户端 id', + 'Client secret': '客户端 secret', + 'Openid configuration': '服务发现地址', + 'Authorization endpoint': '授权端点', + 'Access token endpoint': '令牌端点', + 'JWKS endpoint': 'JWKS 公钥端点', + 'Userinfo endpoint': '用户信息端点', + 'Redirect url': '重定向地址', + 'Logout endpoint': '登出端点', + 'Id token sign alg': 'Id token 签名算法', + 'Add provider': '添加身份提供者', + 'Edit provider': '编辑身份提供者', + 'Delete provider': '删除身份提供者', +}; diff --git a/packages/plugins/oidc/src/client/schemas/oidc.ts b/packages/plugins/oidc/src/client/schemas/oidc.ts new file mode 100644 index 000000000..15dd19c4f --- /dev/null +++ b/packages/plugins/oidc/src/client/schemas/oidc.ts @@ -0,0 +1,451 @@ +import { ISchema } from '@formily/react'; +import { uid } from '@formily/shared'; +import { useActionContext, useRequest } from '@nocobase/client'; +import { IDTOKEN_SIGN_ALG } from '../../server/shared/types'; + +const collection = { + name: 'oidcProviders', + fields: [ + { + type: 'string', + name: 'title', + interface: 'input', + uiSchema: { + title: '{{t("Provider name")}}', + type: 'string', + 'x-component': 'Input', + required: true, + } as ISchema, + }, + { + type: 'string', + name: 'clientId', + interface: 'input', + uiSchema: { + title: '{{t("Client id")}}', + type: 'string', + 'x-component': 'Input', + required: true, + } as ISchema, + }, + { + type: 'string', + name: 'clientSecret', + interface: 'input', + uiSchema: { + title: '{{t("Client secret")}}', + type: 'string', + 'x-component': 'Input', + required: true, + } as ISchema, + }, + { + type: 'string', + name: 'issuer', + interface: 'input', + uiSchema: { + title: '{{t("Issuer")}}', + type: 'string', + 'x-component': 'Input', + required: true, + } as ISchema, + }, + { + type: 'string', + name: 'openidConfiguration', + interface: 'input', + uiSchema: { + title: '{{t("Openid configuration")}}', + type: 'string', + 'x-component': 'Input', + required: true, + } as ISchema, + }, + { + type: 'string', + name: 'authorizeUrl', + interface: 'input', + uiSchema: { + title: '{{t("Authorization endpoint")}}', + type: 'string', + 'x-component': 'Input', + required: true, + } as ISchema, + }, + { + type: 'string', + name: 'tokenUrl', + interface: 'input', + uiSchema: { + title: '{{t("Access token endpoint")}}', + type: 'string', + 'x-component': 'Input', + required: true, + } as ISchema, + }, + { + type: 'string', + name: 'jwksUrl', + interface: 'input', + uiSchema: { + title: '{{t("JWKS endpoint")}}', + type: 'string', + 'x-component': 'Input', + required: true, + } as ISchema, + }, + { + type: 'string', + name: 'userinfoUrl', + interface: 'input', + uiSchema: { + title: '{{t("Userinfo endpoint")}}', + type: 'string', + 'x-component': 'Input', + required: true, + } as ISchema, + }, + { + type: 'string', + name: 'logoutUrl', + interface: 'input', + uiSchema: { + title: '{{t("Logout endpoint")}}', + type: 'string', + 'x-component': 'Input', + required: true, + } as ISchema, + }, + { + type: 'string', + name: 'idTokenSignAlg', + interface: 'select', + uiSchema: { + title: '{{ t("Id token sign alg") }}', + type: 'string', + 'x-component': 'Select', + 'x-component-props': { + showSearch: false, + filterSort: false, + options: [ + { + label: IDTOKEN_SIGN_ALG.RS256, + value: IDTOKEN_SIGN_ALG.RS256, + }, + { + label: IDTOKEN_SIGN_ALG.HS256, + value: IDTOKEN_SIGN_ALG.HS256, + }, + ], + }, + required: true, + } as ISchema, + }, + { + type: 'string', + name: 'redirectUrl', + interface: 'input', + uiSchema: { + title: '{{t("Redirect url")}}', + type: 'string', + 'x-component': 'RedirectURLInput', + } as ISchema, + }, + { + type: 'boolean', + name: 'enabled', + interface: 'boolean', + uiSchema: { + title: '{{t("Enable")}}', + type: 'boolean', + 'x-component': 'Checkbox', + } as ISchema, + }, + ], +}; + +export const formProperties = { + title: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + clientId: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + clientSecret: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + issuer: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + openidConfiguration: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + authorizeUrl: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + tokenUrl: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + jwksUrl: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + userinfoUrl: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + logoutUrl: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + idTokenSignAlg: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + redirectUrl: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + enabled: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + title: '', + 'x-content': '{{t("Enable")}}', + }, +}; + +export const oidcSchema: ISchema = { + type: 'object', + properties: { + block1: { + type: 'void', + 'x-decorator': 'ResourceActionProvider', + 'x-decorator-props': { + collection, + resourceName: 'oidcProviders', + request: { + resource: 'oidcProviders', + action: 'list', + params: { + pageSize: 50, + sort: ['id'], + appends: [], + }, + }, + }, + 'x-component': 'CollectionProvider', + 'x-component-props': { + collection, + }, + properties: { + actions: { + type: 'void', + 'x-component': 'ActionBar', + 'x-component-props': { + style: { + marginBottom: 16, + }, + }, + properties: { + delete: { + type: 'void', + title: '{{ t("Delete") }}', + 'x-component': 'Action', + 'x-component-props': { + useAction: '{{ cm.useBulkDestroyAction }}', + confirm: { + title: "{{t('Delete provider')}}", + content: "{{t('Are you sure you want to delete it?')}}", + }, + }, + }, + create: { + type: 'void', + title: '{{t("Add provider")}}', + 'x-component': 'Action', + 'x-component-props': { + type: 'primary', + }, + properties: { + drawer: { + type: 'void', + 'x-component': 'Action.Drawer', + 'x-decorator': 'Form', + 'x-decorator-props': { + useValues(options) { + const ctx = useActionContext(); + // 初始化数据 + return useRequest( + () => + Promise.resolve({ + data: { + enable: true, + idTokenSignAlg: IDTOKEN_SIGN_ALG.RS256, + }, + }), + { ...options, refreshDeps: [ctx.visible] }, + ); + }, + }, + title: '{{t("Add provider")}}', + properties: { + ...formProperties, + footer: { + type: 'void', + 'x-component': 'Action.Drawer.Footer', + properties: { + cancel: { + title: '{{t("Cancel")}}', + 'x-component': 'Action', + 'x-component-props': { + useAction: '{{ cm.useCancelAction }}', + }, + }, + submit: { + title: '{{t("Submit")}}', + 'x-component': 'Action', + 'x-component-props': { + type: 'primary', + useAction: '{{ cm.useCreateAction }}', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + table: { + type: 'void', + 'x-uid': 'input', + 'x-component': 'Table.Void', + 'x-component-props': { + rowKey: 'id', + rowSelection: { + type: 'checkbox', + }, + useDataSource: '{{ cm.useDataSourceFromRAC }}', + }, + properties: { + column1: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + title: { + type: 'string', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + }, + }, + column2: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + redirectUrl: { + type: 'string', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + }, + }, + column3: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + enabled: { + type: 'boolean', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + }, + }, + column4: { + type: 'void', + title: '{{t("Actions")}}', + 'x-component': 'Table.Column', + properties: { + actions: { + type: 'void', + 'x-component': 'Space', + 'x-component-props': { + split: '|', + }, + properties: { + update: { + type: 'void', + title: '{{t("Edit")}}', + 'x-component': 'Action.Link', + 'x-component-props': { + type: 'primary', + }, + properties: { + drawer: { + type: 'void', + 'x-component': 'Action.Drawer', + 'x-decorator': 'Form', + 'x-decorator-props': { + useValues: '{{ cm.useValuesFromRecord }}', + }, + title: '{{t("Edit provider")}}', + properties: { + ...formProperties, + footer: { + type: 'void', + 'x-component': 'Action.Drawer.Footer', + properties: { + cancel: { + title: '{{t("Cancel")}}', + 'x-component': 'Action', + 'x-component-props': { + useAction: '{{ cm.useCancelAction }}', + }, + }, + submit: { + title: '{{t("Submit")}}', + 'x-component': 'Action', + 'x-component-props': { + type: 'primary', + useAction: '{{ cm.useUpdateAction }}', + }, + }, + }, + }, + }, + }, + }, + }, + delete: { + type: 'void', + title: '{{ t("Delete") }}', + 'x-component': 'Action.Link', + 'x-component-props': { + confirm: { + title: "{{t('Delete role')}}", + content: "{{t('Are you sure you want to delete it?')}}", + }, + useAction: '{{cm.useDestroyAction}}', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/packages/plugins/oidc/src/index.ts b/packages/plugins/oidc/src/index.ts new file mode 100644 index 000000000..179594196 --- /dev/null +++ b/packages/plugins/oidc/src/index.ts @@ -0,0 +1,3 @@ +export { default } from './server'; + +export const namespace = require('../package.json').name; diff --git a/packages/plugins/oidc/src/server/actions/getAuthUrl.ts b/packages/plugins/oidc/src/server/actions/getAuthUrl.ts new file mode 100644 index 000000000..328881523 --- /dev/null +++ b/packages/plugins/oidc/src/server/actions/getAuthUrl.ts @@ -0,0 +1,24 @@ +import { Context } from '@nocobase/actions'; +import { createOIDCClient } from '../shared/createOIDCClient'; +import { OIDCProvider } from '../shared/types'; + +export const getAuthUrl = async (ctx: Context, next) => { + const { + params: { values }, + } = ctx.action; + const providerRepo = ctx.db.getRepository('oidcProviders'); + const record = await providerRepo.findOne({ + filter: { + clientId: values.clientId, + }, + }); + const provider: OIDCProvider = record.toJSON(); + const client = await createOIDCClient(provider); + + ctx.body = client.authorizationUrl({ + nonce: ctx.OIDC_NONCE, + scope: 'openid profile', + }); + + return next(); +}; diff --git a/packages/plugins/oidc/src/server/actions/redirect.ts b/packages/plugins/oidc/src/server/actions/redirect.ts new file mode 100644 index 000000000..5c2aa1858 --- /dev/null +++ b/packages/plugins/oidc/src/server/actions/redirect.ts @@ -0,0 +1,27 @@ +import { Context } from '@nocobase/actions'; + +export const redirect = async (ctx: Context, next) => { + const { params } = ctx.action; + + const template = ` + + + + + + + + + + + + + `; + + ctx.body = template; + ctx.withoutDataWrapping = true; + + await next(); +}; diff --git a/packages/plugins/oidc/src/server/authenticators/oidc.ts b/packages/plugins/oidc/src/server/authenticators/oidc.ts new file mode 100644 index 000000000..1924aed96 --- /dev/null +++ b/packages/plugins/oidc/src/server/authenticators/oidc.ts @@ -0,0 +1,52 @@ +import { Context } from '@nocobase/actions'; +import { createOIDCClient } from '../shared/createOIDCClient'; +import { OIDCProvider } from '../shared/types'; + +export const oidc = async (ctx: Context, next) => { + const { + params: { values }, + } = ctx.action; + + const providerRepo = ctx.db.getRepository('oidcProviders'); + const record = await providerRepo.findOne({ + filter: { + clientId: values.clientId, + }, + }); + const provider: OIDCProvider = record.toJSON(); + const client = await createOIDCClient(provider); + + const tokens = await client.callback( + provider.redirectUrl, + { + code: values.code, + }, + { + nonce: ctx.OIDC_NONCE, + }, + ); + + const userinfo = await client.userinfo(tokens.access_token); + const usersRepo = ctx.db.getRepository('users'); + + const name = userinfo.preferred_username || userinfo.nickname || userinfo.name; + + let user = await usersRepo.findOne({ + filter: { + nickname: name, + email: userinfo.email ?? null, + }, + }); + + if (!user) { + user = await usersRepo.create({ + values: { + nickname: name, + }, + }); + } + + ctx.state.currentUser = user; + + return next(); +}; diff --git a/packages/plugins/oidc/src/server/collections/.gitkeep b/packages/plugins/oidc/src/server/collections/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/plugins/oidc/src/server/collections/oidcProviders.ts b/packages/plugins/oidc/src/server/collections/oidcProviders.ts new file mode 100644 index 000000000..bf51755ca --- /dev/null +++ b/packages/plugins/oidc/src/server/collections/oidcProviders.ts @@ -0,0 +1,86 @@ +import { CollectionOptions } from '@nocobase/database'; + +export default { + name: 'oidcProviders', + title: '{{t("OIDC Providers")}}', + fields: [ + { + title: 'Provider 名称', + comment: 'Provider 名称', + type: 'string', + name: 'title', + }, + { + comment: '客户端id', + type: 'string', + name: 'clientId', + unique: true, + }, + { + comment: '客户端密钥', + type: 'string', + name: 'clientSecret', + }, + { + title: 'issuer', + comment: '用于标识 token 发放来源的字段', + type: 'string', + name: 'issuer', + }, + { + comment: '用于获取当前 Provider 支持的各端点信息和支持的模式、参数信息,可公开访问', + type: 'string', + name: 'openidConfiguration', + }, + { + comment: '单点登录的地址 ', + type: 'string', + name: 'authorizeUrl', + }, + { + comment: '获取取 token 的接口地址', + type: 'string', + name: 'tokenUrl', + }, + { + comment: '令牌吊销 token 的接口', + type: 'string', + name: 'revokeUrl', + }, + { + comment: '验证 id_token 的接口', + type: 'string', + name: 'jwksUrl', + }, + { + comment: '获取用户信息的接口', + type: 'string', + name: 'userinfoUrl', + }, + { + comment: '是否启用', + type: 'boolean', + name: 'enabled', + }, + { + comment: '重定向回调地址', + type: 'string', + name: 'redirectUrl', + }, + { + comment: '登出端点', + type: 'string', + name: 'logoutUrl', + }, + { + comment: 'id token 加密方式', + type: 'string', + name: 'idTokenSignAlg', + }, + { + comment: '获取用户信息的接口', + type: 'json', + name: 'attrsMap', + }, + ], +} as CollectionOptions; diff --git a/packages/plugins/oidc/src/server/index.ts b/packages/plugins/oidc/src/server/index.ts new file mode 100644 index 000000000..b68aea57f --- /dev/null +++ b/packages/plugins/oidc/src/server/index.ts @@ -0,0 +1 @@ +export { default } from './plugin'; diff --git a/packages/plugins/oidc/src/server/plugin.ts b/packages/plugins/oidc/src/server/plugin.ts new file mode 100644 index 000000000..5341083a3 --- /dev/null +++ b/packages/plugins/oidc/src/server/plugin.ts @@ -0,0 +1,62 @@ +import UsersPlugin from '@nocobase/plugin-users'; +import { InstallOptions, Plugin } from '@nocobase/server'; +import { resolve } from 'path'; +import { generators } from 'openid-client'; +import { oidc } from './authenticators/oidc'; +import { getAuthUrl } from './actions/getAuthUrl'; +import { redirect } from './actions/redirect'; + +export class OidcPlugin extends Plugin { + #OIDC_NONCE = null; + + afterAdd() {} + + beforeLoad() {} + + async load() { + // 导入 collection + await this.db.import({ + directory: resolve(__dirname, 'collections'), + }); + + // 获取 User 插件 + const userPlugin = this.app.getPlugin('users') as UsersPlugin; + + // 注册 OIDC 验证器 + userPlugin.authenticators.register('oidc', oidc); + + // 注册接口 + this.app.resource({ + name: 'oidc', + actions: { + getAuthUrl, + redirect, + }, + }); + + // 注册中间件,处理 nonce 值 + this.app.use(async (ctx, next) => { + if (ctx.url.startsWith('/api/users:signin?authenticator=oidc')) { + ctx.OIDC_NONCE = this.#OIDC_NONCE; + } + if (ctx.url.startsWith('/api/oidc:getAuthUrl')) { + ctx.OIDC_NONCE = this.#OIDC_NONCE = generators.nonce(); + } + await next(); + }); + + // 开放访问权限 + this.app.acl.allow('oidcProviders', '*'); + this.app.acl.allow('oidc', '*'); + } + + async install(options?: InstallOptions) {} + + async afterEnable() {} + + async afterDisable() {} + + async remove() {} +} + +export default OidcPlugin; diff --git a/packages/plugins/oidc/src/server/shared/createOIDCClient.ts b/packages/plugins/oidc/src/server/shared/createOIDCClient.ts new file mode 100644 index 000000000..1de2676cd --- /dev/null +++ b/packages/plugins/oidc/src/server/shared/createOIDCClient.ts @@ -0,0 +1,12 @@ +import { Issuer } from 'openid-client'; +import { IDTOKEN_SIGN_ALG, OIDCProvider } from './types'; + +export const createOIDCClient = async (provider: OIDCProvider) => { + const issuer = await Issuer.discover(provider.issuer); + return new issuer.Client({ + client_id: provider.clientId, + client_secret: provider.clientSecret, + response_types: ['code'], + id_token_signed_response_alg: provider.idTokenSignAlg, + }); +}; diff --git a/packages/plugins/oidc/src/server/shared/types.ts b/packages/plugins/oidc/src/server/shared/types.ts new file mode 100644 index 000000000..ffc340fbc --- /dev/null +++ b/packages/plugins/oidc/src/server/shared/types.ts @@ -0,0 +1,22 @@ +export enum IDTOKEN_SIGN_ALG { + HS256 = 'HS256', + RS256 = 'RS256', +} + +export interface OIDCProvider { + title?: string; + clientId?: string; + clientSecret?: string; + issuer?: string; + openidConfiguration?: string; + authorizeUrl?: string; + tokenUrl?: string; + revodeUrl?: string; + jwksUrl?: string; + userinfoUrl?: string; + enable?: string; + redirectUrl?: string; + logoutUrl?: string; + idTokenSignAlg?: string; + attrsMap?: Record; +} diff --git a/yarn.lock b/yarn.lock index 1f7c9cdbf..b647eeaa8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23571,4 +23571,4 @@ yocto-queue@^0.1.0: zwitch@^1.0.0: version "1.0.5" resolved "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920" - integrity sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw== + integrity sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw== \ No newline at end of file