feat: saml (#1143)
* feat: saml * feat: saml i18n fix * feat: saml extract getSaml * feat: saml signin extension * feat: saml remove $eq * feat: saml validate fix * feat: saml page extension fix * feat: saml remove canceltoken Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
parent
1ac0032e5c
commit
59d32937c8
1
packages/app/client/src/plugins/saml.ts
Normal file
1
packages/app/client/src/plugins/saml.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from '@nocobase/plugin-saml/client';
|
4
packages/plugins/saml/client.d.ts
vendored
Executable file
4
packages/plugins/saml/client.d.ts
vendored
Executable file
@ -0,0 +1,4 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
export * from './lib/client';
|
||||||
|
export { default } from './lib/client';
|
||||||
|
|
30
packages/plugins/saml/client.js
Executable file
30
packages/plugins/saml/client.js
Executable file
@ -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];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
12
packages/plugins/saml/package.json
Normal file
12
packages/plugins/saml/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
4
packages/plugins/saml/server.d.ts
vendored
Executable file
4
packages/plugins/saml/server.d.ts
vendored
Executable file
@ -0,0 +1,4 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
export * from './lib/server';
|
||||||
|
export { default } from './lib/server';
|
||||||
|
|
30
packages/plugins/saml/server.js
Executable file
30
packages/plugins/saml/server.js
Executable file
@ -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];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
27
packages/plugins/saml/src/client/RedirectURLInput.tsx
Normal file
27
packages/plugins/saml/src/client/RedirectURLInput.tsx
Normal file
@ -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<Field>();
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<FormLayout layout={'vertical'}>
|
||||||
|
<Input disabled value={field.value} />
|
||||||
|
</FormLayout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
100
packages/plugins/saml/src/client/SAMLList.tsx
Normal file
100
packages/plugins/saml/src/client/SAMLList.tsx
Normal file
@ -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<SAMLProvider[]>([]);
|
||||||
|
const [windowHandler, setWindowHandler] = useState<Window | undefined>();
|
||||||
|
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 (
|
||||||
|
<Space
|
||||||
|
direction="vertical"
|
||||||
|
className={css`
|
||||||
|
display: flex;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{list.map((item) => (
|
||||||
|
<Button shape="round" block key={item.clientId} icon={<LoginOutlined />} onClick={() => handleOpen(item)}>
|
||||||
|
SAML: {item.title}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
};
|
24
packages/plugins/saml/src/client/SAMLPanel.tsx
Normal file
24
packages/plugins/saml/src/client/SAMLPanel.tsx
Normal file
@ -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 (
|
||||||
|
<Card bordered={false}>
|
||||||
|
<SchemaComponent components={{ RedirectURLInput }} schema={schema} scope={{ t }} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
43
packages/plugins/saml/src/client/index.tsx
Normal file
43
packages/plugins/saml/src/client/index.tsx
Normal file
@ -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 (
|
||||||
|
<SigninPageExtensionProvider component={SAMLList}>
|
||||||
|
<SettingsCenterProvider
|
||||||
|
settings={{
|
||||||
|
'saml-manager': {
|
||||||
|
title: t('SAML manager'),
|
||||||
|
icon: 'FileOutlined',
|
||||||
|
tabs: {
|
||||||
|
storages: {
|
||||||
|
title: t('SAML Providers'),
|
||||||
|
component: SAMLPanel,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
scope={{
|
||||||
|
t,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PluginManagerContext.Provider
|
||||||
|
value={{
|
||||||
|
components: {
|
||||||
|
...ctx?.components,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</PluginManagerContext.Provider>
|
||||||
|
</SettingsCenterProvider>
|
||||||
|
</SigninPageExtensionProvider>
|
||||||
|
);
|
||||||
|
}
|
20
packages/plugins/saml/src/client/locale/en-US.ts
Normal file
20
packages/plugins/saml/src/client/locale/en-US.ts
Normal file
@ -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?',
|
||||||
|
};
|
24
packages/plugins/saml/src/client/locale/index.ts
Normal file
24
packages/plugins/saml/src/client/locale/index.ts
Normal file
@ -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);
|
||||||
|
}
|
1
packages/plugins/saml/src/client/locale/ja-JP.ts
Normal file
1
packages/plugins/saml/src/client/locale/ja-JP.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default {};
|
1
packages/plugins/saml/src/client/locale/ru-RU.ts
Normal file
1
packages/plugins/saml/src/client/locale/ru-RU.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default {};
|
1
packages/plugins/saml/src/client/locale/tr-TR.ts
Normal file
1
packages/plugins/saml/src/client/locale/tr-TR.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default {};
|
21
packages/plugins/saml/src/client/locale/zh-CN.ts
Normal file
21
packages/plugins/saml/src/client/locale/zh-CN.ts
Normal file
@ -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?': '你确定要删除它吗?',
|
||||||
|
};
|
355
packages/plugins/saml/src/client/schemas/saml.ts
Normal file
355
packages/plugins/saml/src/client/schemas/saml.ts
Normal file
@ -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}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
3
packages/plugins/saml/src/index.ts
Normal file
3
packages/plugins/saml/src/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { default } from './server';
|
||||||
|
|
||||||
|
export const namespace = require('../package.json').name;
|
22
packages/plugins/saml/src/server/actions/getAuthUrl.ts
Normal file
22
packages/plugins/saml/src/server/actions/getAuthUrl.ts
Normal file
@ -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();
|
||||||
|
};
|
23
packages/plugins/saml/src/server/actions/metadata.ts
Normal file
23
packages/plugins/saml/src/server/actions/metadata.ts
Normal file
@ -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();
|
||||||
|
};
|
27
packages/plugins/saml/src/server/actions/redirect.ts
Normal file
27
packages/plugins/saml/src/server/actions/redirect.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { Context } from '@nocobase/actions';
|
||||||
|
|
||||||
|
export const redirect = async (ctx: Context, next) => {
|
||||||
|
const { params } = ctx.action;
|
||||||
|
|
||||||
|
const template = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title></title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
window.opener.postMessage(${JSON.stringify({ clientId: params.clientId, samlResponse: params.values })}, '*');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
ctx.body = template;
|
||||||
|
ctx.withoutDataWrapping = true;
|
||||||
|
|
||||||
|
await next();
|
||||||
|
};
|
48
packages/plugins/saml/src/server/authenticators/saml.ts
Normal file
48
packages/plugins/saml/src/server/authenticators/saml.ts
Normal file
@ -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<string, string>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
};
|
@ -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;
|
1
packages/plugins/saml/src/server/index.ts
Normal file
1
packages/plugins/saml/src/server/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from './plugin';
|
50
packages/plugins/saml/src/server/plugin.ts
Normal file
50
packages/plugins/saml/src/server/plugin.ts
Normal file
@ -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;
|
14
packages/plugins/saml/src/server/shared/getSaml.ts
Normal file
14
packages/plugins/saml/src/server/shared/getSaml.ts
Normal file
@ -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);
|
||||||
|
};
|
9
packages/plugins/saml/src/server/shared/types.ts
Normal file
9
packages/plugins/saml/src/server/shared/types.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export interface SAMLProvider {
|
||||||
|
title?: string;
|
||||||
|
clientId?: string;
|
||||||
|
issuer?: string;
|
||||||
|
loginUrl?: string;
|
||||||
|
certificate?: string;
|
||||||
|
redirectUrl?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user