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:
anuoua 2022-11-29 23:20:33 +08:00 committed by GitHub
parent 1ac0032e5c
commit 59d32937c8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 938 additions and 0 deletions

View File

@ -0,0 +1 @@
export { default } from '@nocobase/plugin-saml/client';

4
packages/plugins/saml/client.d.ts vendored Executable file
View File

@ -0,0 +1,4 @@
// @ts-nocheck
export * from './lib/client';
export { default } from './lib/client';

30
packages/plugins/saml/client.js Executable file
View 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];
}
});
});

View 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
View File

@ -0,0 +1,4 @@
// @ts-nocheck
export * from './lib/server';
export { default } from './lib/server';

30
packages/plugins/saml/server.js Executable file
View 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];
}
});
});

View 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>
);
});

View 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>
);
};

View 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>
);
};

View 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>
);
}

View 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?',
};

View 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);
}

View File

@ -0,0 +1 @@
export default {};

View File

@ -0,0 +1 @@
export default {};

View File

@ -0,0 +1 @@
export default {};

View 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?': '你确定要删除它吗?',
};

View 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}}',
},
},
},
},
},
},
},
},
},
},
},
};

View File

@ -0,0 +1,3 @@
export { default } from './server';
export const namespace = require('../package.json').name;

View 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();
};

View 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();
};

View 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();
};

View 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();
};

View File

@ -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;

View File

@ -0,0 +1 @@
export { default } from './plugin';

View 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;

View 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);
};

View File

@ -0,0 +1,9 @@
export interface SAMLProvider {
title?: string;
clientId?: string;
issuer?: string;
loginUrl?: string;
certificate?: string;
redirectUrl?: string;
enabled?: boolean;
}