diff --git a/packages/app/client/src/plugins/saml.ts b/packages/app/client/src/plugins/saml.ts new file mode 100644 index 000000000..d4e22a332 --- /dev/null +++ b/packages/app/client/src/plugins/saml.ts @@ -0,0 +1 @@ +export { default } from '@nocobase/plugin-saml/client'; \ No newline at end of file diff --git a/packages/plugins/saml/client.d.ts b/packages/plugins/saml/client.d.ts new file mode 100755 index 000000000..765db9222 --- /dev/null +++ b/packages/plugins/saml/client.d.ts @@ -0,0 +1,4 @@ +// @ts-nocheck +export * from './lib/client'; +export { default } from './lib/client'; + diff --git a/packages/plugins/saml/client.js b/packages/plugins/saml/client.js new file mode 100755 index 000000000..238820257 --- /dev/null +++ b/packages/plugins/saml/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/saml/package.json b/packages/plugins/saml/package.json new file mode 100644 index 000000000..082df4973 --- /dev/null +++ b/packages/plugins/saml/package.json @@ -0,0 +1,12 @@ +{ + "name": "@nocobase/plugin-saml", + "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": { + "@node-saml/node-saml": "^4.0.2" + } +} diff --git a/packages/plugins/saml/server.d.ts b/packages/plugins/saml/server.d.ts new file mode 100755 index 000000000..e70edb928 --- /dev/null +++ b/packages/plugins/saml/server.d.ts @@ -0,0 +1,4 @@ +// @ts-nocheck +export * from './lib/server'; +export { default } from './lib/server'; + diff --git a/packages/plugins/saml/server.js b/packages/plugins/saml/server.js new file mode 100755 index 000000000..d06a7eb92 --- /dev/null +++ b/packages/plugins/saml/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/saml/src/client/RedirectURLInput.tsx b/packages/plugins/saml/src/client/RedirectURLInput.tsx new file mode 100644 index 000000000..0ebfadba5 --- /dev/null +++ b/packages/plugins/saml/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/saml:redirect?clientId=${clientId}`); + }, [clientId]); + + return ( +
+ + + +
+ ); +}); diff --git a/packages/plugins/saml/src/client/SAMLList.tsx b/packages/plugins/saml/src/client/SAMLList.tsx new file mode 100644 index 000000000..28ef8733a --- /dev/null +++ b/packages/plugins/saml/src/client/SAMLList.tsx @@ -0,0 +1,100 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { Button, Space } from 'antd'; +import { LoginOutlined } from '@ant-design/icons'; +import { css } from '@emotion/css'; +import { useAPIClient, useRedirect } from '@nocobase/client'; + +export interface SAMLProvider { + title: string; + clientId: string; + loginUrl: string; +} + +export const SAMLList = () => { + const [list, setList] = useState([]); + const [windowHandler, setWindowHandler] = useState(); + const api = useAPIClient(); + const redirect = useRedirect(); + + const getSamlList = async () => { + const { data: pluginsRes } = await api.request({ + url: 'app:getPlugins', + }); + if (!(pluginsRes.data as string[]).includes('saml')) return; + const { data: providersRes } = await api.request({ + url: 'samlProviders:list', + params: { + filter: { + enabled: true, + }, + }, + }); + setList(providersRes.data); + }; + + /** + * 打开登录弹出框 + */ + const handleOpen = async (item: SAMLProvider) => { + const response = await api.request({ + method: 'post', + url: 'saml: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 = async (event: MessageEvent) => { + await api.auth.signIn(event.data, 'saml'); + windowHandler.close(); + setWindowHandler(undefined); + redirect(); + }; + + /** + * 监听弹出窗口的消息 + */ + useEffect(() => { + if (!windowHandler) return; + window.addEventListener('message', handleOIDCLogin); + return () => { + window.removeEventListener('message', handleOIDCLogin); + }; + }, [windowHandler]); + + useEffect(() => { + getSamlList(); + }, []); + + return ( + + {list.map((item) => ( + + ))} + + ); +}; diff --git a/packages/plugins/saml/src/client/SAMLPanel.tsx b/packages/plugins/saml/src/client/SAMLPanel.tsx new file mode 100644 index 000000000..fdd0f80e4 --- /dev/null +++ b/packages/plugins/saml/src/client/SAMLPanel.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { uid } from '@formily/shared'; +import { SchemaComponent, useRecord } from '@nocobase/client'; +import { Card } from 'antd'; +import { samlSchema } from './schemas/saml'; +import { RedirectURLInput } from './RedirectURLInput'; +import { useSamlTranslation } from './locale'; + +const schema = { + type: 'object', + properties: { + [uid()]: samlSchema, + }, +}; + +export const SAMLPanel = () => { + const { t } = useSamlTranslation(); + + return ( + + + + ); +}; diff --git a/packages/plugins/saml/src/client/index.tsx b/packages/plugins/saml/src/client/index.tsx new file mode 100644 index 000000000..4fcb19fee --- /dev/null +++ b/packages/plugins/saml/src/client/index.tsx @@ -0,0 +1,43 @@ +import React, { useContext } from 'react'; +import { PluginManagerContext, SettingsCenterProvider } from '@nocobase/client'; +import { SigninPageExtensionProvider } from '@nocobase/client'; +import { useSamlTranslation } from './locale'; +import { SAMLList } from './SAMLList'; +import { SAMLPanel } from './SAMLPanel'; + +export default function (props) { + const ctx = useContext(PluginManagerContext); + const { t } = useSamlTranslation(); + + return ( + + + + {props.children} + + + + ); +} diff --git a/packages/plugins/saml/src/client/locale/en-US.ts b/packages/plugins/saml/src/client/locale/en-US.ts new file mode 100644 index 000000000..4da4b3f27 --- /dev/null +++ b/packages/plugins/saml/src/client/locale/en-US.ts @@ -0,0 +1,20 @@ +export default { + Edit: 'Edit', + Delete: 'Delete', + Cancel: 'Cancel', + Submit: 'Submit', + Actions: 'Actions', + Title: 'Title', + Enable: 'Enable', + 'SAML manager': 'SAML manager', + 'SAML Providers': 'SAML Providers', + 'Redirect url': 'Redirect url', + 'SP entity id': 'SP entity id', + 'Add provider': 'Add provider', + 'Edit provider': 'Edit provider', + 'Client id': 'Client id', + 'Entity id or issuer': 'Entity id or issuer', + 'Login Url': 'Login Url', + 'Public cert': 'Public cert', + 'Are you sure you want to delete it?': 'Are you sure you want to delete it?', +}; diff --git a/packages/plugins/saml/src/client/locale/index.ts b/packages/plugins/saml/src/client/locale/index.ts new file mode 100644 index 000000000..118598752 --- /dev/null +++ b/packages/plugins/saml/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 useSamlTranslation() { + return useTranslation(NAMESPACE); +} diff --git a/packages/plugins/saml/src/client/locale/ja-JP.ts b/packages/plugins/saml/src/client/locale/ja-JP.ts new file mode 100644 index 000000000..ff8b4c563 --- /dev/null +++ b/packages/plugins/saml/src/client/locale/ja-JP.ts @@ -0,0 +1 @@ +export default {}; diff --git a/packages/plugins/saml/src/client/locale/ru-RU.ts b/packages/plugins/saml/src/client/locale/ru-RU.ts new file mode 100644 index 000000000..ff8b4c563 --- /dev/null +++ b/packages/plugins/saml/src/client/locale/ru-RU.ts @@ -0,0 +1 @@ +export default {}; diff --git a/packages/plugins/saml/src/client/locale/tr-TR.ts b/packages/plugins/saml/src/client/locale/tr-TR.ts new file mode 100644 index 000000000..ff8b4c563 --- /dev/null +++ b/packages/plugins/saml/src/client/locale/tr-TR.ts @@ -0,0 +1 @@ +export default {}; diff --git a/packages/plugins/saml/src/client/locale/zh-CN.ts b/packages/plugins/saml/src/client/locale/zh-CN.ts new file mode 100644 index 000000000..93d274fee --- /dev/null +++ b/packages/plugins/saml/src/client/locale/zh-CN.ts @@ -0,0 +1,21 @@ +export default { + Edit: '编辑', + Delete: '删除', + Cancel: '取消', + Submit: '提交', + Actions: '操作', + Title: '身份提供者名称', + Enable: '启用', + 'SAML manager': 'SAML 管理', + 'SAML Providers': 'SAML 身份提供者', + 'Redirect url': '重定向地址', + 'SP entity id': '应用唯一标识(SP Entity ID)', + 'Add provider': '添加身份提供者', + 'Edit provider': '编辑身份提供者', + 'Client id': '客户端 id', + 'Entity id or issuer': 'IdP 唯一标识', + 'Login Url': '登录地址', + 'Public cert': '公钥', + 'Delete provider': '删除身份提供者', + 'Are you sure you want to delete it?': '你确定要删除它吗?', +}; diff --git a/packages/plugins/saml/src/client/schemas/saml.ts b/packages/plugins/saml/src/client/schemas/saml.ts new file mode 100644 index 000000000..e5b01ca02 --- /dev/null +++ b/packages/plugins/saml/src/client/schemas/saml.ts @@ -0,0 +1,355 @@ +import { ISchema } from '@formily/react'; +import { useActionContext, useRequest } from '@nocobase/client'; + +const collection = { + name: 'samlProviders', + fields: [ + { + type: 'string', + name: 'title', + interface: 'input', + uiSchema: { + title: '{{t("Title")}}', + 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: 'issuer', + interface: 'input', + uiSchema: { + title: '{{t("Entity id or issuer")}}', + type: 'string', + 'x-component': 'Input', + required: true, + } as ISchema, + }, + { + type: 'string', + name: 'loginUrl', + interface: 'input', + uiSchema: { + title: '{{t("Login Url")}}', + type: 'string', + 'x-component': 'Input', + required: true, + } as ISchema, + }, + { + type: 'string', + name: 'certificate', + interface: 'input', + uiSchema: { + title: '{{t("Public cert")}}', + type: 'string', + 'x-component': 'Input.TextArea', + required: true, + } as ISchema, + }, + { + type: 'string', + name: 'certificate', + interface: 'input', + uiSchema: { + title: '{{t("Public cert")}}', + type: 'string', + 'x-component': 'Input.TextArea', + 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', + }, + issuer: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + loginUrl: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + certificate: { + '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 samlSchema: ISchema = { + type: 'object', + properties: { + block1: { + type: 'void', + 'x-decorator': 'ResourceActionProvider', + 'x-decorator-props': { + collection, + resourceName: 'samlProviders', + request: { + resource: 'samlProviders', + 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, + }, + }), + { ...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, + }, + }, + }, + column4: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + enabled: { + type: 'boolean', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + }, + }, + column5: { + 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/saml/src/index.ts b/packages/plugins/saml/src/index.ts new file mode 100644 index 000000000..179594196 --- /dev/null +++ b/packages/plugins/saml/src/index.ts @@ -0,0 +1,3 @@ +export { default } from './server'; + +export const namespace = require('../package.json').name; diff --git a/packages/plugins/saml/src/server/actions/getAuthUrl.ts b/packages/plugins/saml/src/server/actions/getAuthUrl.ts new file mode 100644 index 000000000..f3b52b6af --- /dev/null +++ b/packages/plugins/saml/src/server/actions/getAuthUrl.ts @@ -0,0 +1,22 @@ +import { Context } from '@nocobase/actions'; +import { getSaml } from '../shared/getSaml'; +import { SAMLProvider } from '../shared/types'; + +export const getAuthUrl = async (ctx: Context, next) => { + const { + params: { values }, + } = ctx.action; + const providerRepo = ctx.db.getRepository('samlProviders'); + + const record: SAMLProvider = await providerRepo.findOne({ + filter: { + clientId: values.clientId, + }, + }); + + const saml = getSaml(record); + + ctx.body = await saml.getAuthorizeUrlAsync('', '', {}); + + return next(); +}; diff --git a/packages/plugins/saml/src/server/actions/metadata.ts b/packages/plugins/saml/src/server/actions/metadata.ts new file mode 100644 index 000000000..a5bfec0e7 --- /dev/null +++ b/packages/plugins/saml/src/server/actions/metadata.ts @@ -0,0 +1,23 @@ +import { Context } from '@nocobase/actions'; +import { getSaml } from '../shared/getSaml'; + +export const metadata = async (ctx: Context, next) => { + const { + params: { clientId }, + } = ctx.action; + const providerRepo = ctx.db.getRepository('samlProviders'); + + const record = await providerRepo.findOne({ + filter: { + clientId: clientId, + }, + }); + + const saml = getSaml(record); + + ctx.type = 'text/xml'; + ctx.body = saml.generateServiceProviderMetadata(record.certificate); + ctx.withoutDataWrapping = true; + + return next(); +}; diff --git a/packages/plugins/saml/src/server/actions/redirect.ts b/packages/plugins/saml/src/server/actions/redirect.ts new file mode 100644 index 000000000..81cb0f3a9 --- /dev/null +++ b/packages/plugins/saml/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/saml/src/server/authenticators/saml.ts b/packages/plugins/saml/src/server/authenticators/saml.ts new file mode 100644 index 000000000..d2de65fe8 --- /dev/null +++ b/packages/plugins/saml/src/server/authenticators/saml.ts @@ -0,0 +1,48 @@ +import { Context } from '@nocobase/actions'; +import { SAML, SamlConfig } from '@node-saml/node-saml'; +import { getSaml } from '../shared/getSaml'; +import { SAMLProvider } from '../shared/types'; + +export const saml = async (ctx: Context, next) => { + const { + params: { + values: { clientId, samlResponse }, + }, + } = ctx.action; + + const providerRepo = ctx.db.getRepository('samlProviders'); + const record: SAMLProvider = await providerRepo.findOne({ + filter: { + clientId: clientId, + }, + }); + + const saml = getSaml(record); + + const { profile } = await saml.validatePostResponseAsync(samlResponse); + + const usersRepo = ctx.db.getRepository('users'); + + const { nameID, nickname, username, email } = profile as Record; + + const name = nickname ?? username ?? nameID; + + let user = await usersRepo.findOne({ + filter: { + nickname: name, + email: email ?? null, + }, + }); + + if (!user) { + user = await usersRepo.create({ + values: { + nickname: name, + }, + }); + } + + ctx.state.currentUser = user; + + return next(); +}; diff --git a/packages/plugins/saml/src/server/collections/.gitkeep b/packages/plugins/saml/src/server/collections/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/plugins/saml/src/server/collections/samlProviders.ts b/packages/plugins/saml/src/server/collections/samlProviders.ts new file mode 100644 index 000000000..9545689f2 --- /dev/null +++ b/packages/plugins/saml/src/server/collections/samlProviders.ts @@ -0,0 +1,43 @@ +import { CollectionOptions } from '@nocobase/database'; + +export default { + name: 'samlProviders', + title: '{{t("SAML Providers")}}', + fields: [ + { + comment: '标题', + type: 'string', + name: 'title', + }, + { + comment: '客户端id', + type: 'string', + name: 'clientId', + }, + { + comment: '唯一标识(entityId/Issuer)', + type: 'string', + name: 'issuer', + }, + { + comment: '登录地址(ACS)', + type: 'string', + name: 'loginUrl', + }, + { + comment: '公钥', + type: 'text', + name: 'certificate', + }, + { + comment: '重定向地址', + type: 'text', + name: 'redirectUrl', + }, + { + comment: '启用', + type: 'boolean', + name: 'enabled', + }, + ], +} as CollectionOptions; diff --git a/packages/plugins/saml/src/server/index.ts b/packages/plugins/saml/src/server/index.ts new file mode 100644 index 000000000..b68aea57f --- /dev/null +++ b/packages/plugins/saml/src/server/index.ts @@ -0,0 +1 @@ +export { default } from './plugin'; diff --git a/packages/plugins/saml/src/server/plugin.ts b/packages/plugins/saml/src/server/plugin.ts new file mode 100644 index 000000000..c79cb37a3 --- /dev/null +++ b/packages/plugins/saml/src/server/plugin.ts @@ -0,0 +1,50 @@ +import UsersPlugin from 'packages/plugins/users/src/server'; +import { InstallOptions, Plugin } from '@nocobase/server'; +import { resolve } from 'path'; +import { saml } from './authenticators/saml'; +import { redirect } from './actions/redirect'; +import { metadata } from './actions/metadata'; +import { getAuthUrl } from './actions/getAuthUrl'; + +export class SAMLPlugin extends Plugin { + afterAdd() {} + + beforeLoad() {} + + async load() { + // 导入 collection + await this.db.import({ + directory: resolve(__dirname, 'collections'), + }); + + // 获取 User 插件 + const userPlugin = this.app.getPlugin('users') as UsersPlugin; + + // 注册 SAML 验证器 + userPlugin.authenticators.register('saml', saml); + + // 注册接口 + this.app.resource({ + name: 'saml', + actions: { + redirect, + metadata, + getAuthUrl, + }, + }); + + // 开放访问权限 + this.app.acl.allow('samlProviders', '*'); + this.app.acl.allow('saml', '*'); + } + + async install(options?: InstallOptions) {} + + async afterEnable() {} + + async afterDisable() {} + + async remove() {} +} + +export default SAMLPlugin; diff --git a/packages/plugins/saml/src/server/shared/getSaml.ts b/packages/plugins/saml/src/server/shared/getSaml.ts new file mode 100644 index 000000000..9272c8586 --- /dev/null +++ b/packages/plugins/saml/src/server/shared/getSaml.ts @@ -0,0 +1,14 @@ +import { SAML, SamlConfig } from '@node-saml/node-saml'; +import { SAMLProvider } from './types'; + +export const getSaml = (provider: SAMLProvider) => { + const options: SamlConfig = { + entryPoint: provider.loginUrl, + issuer: provider.issuer, + cert: provider.certificate, + audience: false, + wantAuthnResponseSigned: false, + }; + + return new SAML(options); +}; diff --git a/packages/plugins/saml/src/server/shared/types.ts b/packages/plugins/saml/src/server/shared/types.ts new file mode 100644 index 000000000..9747500a3 --- /dev/null +++ b/packages/plugins/saml/src/server/shared/types.ts @@ -0,0 +1,9 @@ +export interface SAMLProvider { + title?: string; + clientId?: string; + issuer?: string; + loginUrl?: string; + certificate?: string; + redirectUrl?: string; + enabled?: boolean; +}