feat(plugin-cas): support cas authenticator (#2580)
* feat(plugin-cas): support cas login method * feat: add cas plugin in preset * chore: update version * feat: full support cas * chore: update package.json * feat: update docs and fix namespace * fix: locale --------- Co-authored-by: xilesun <2013xile@gmail.com>
This commit is contained in:
parent
e7d60389b9
commit
24179c4469
12
packages/plugins/cas/README.md
Normal file
12
packages/plugins/cas/README.md
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# @nocobase/plugin-cas
|
||||||
|
|
||||||
|
提供 CAS 统一登入方式
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
> 首先你要开启此插件
|
||||||
|
|
||||||
|
1. 在 Authentication 中添加一个 Auth Type 为 CAS 类型的登录方式
|
||||||
|
<img src="https://github.com/nocobase/nocobase/assets/29533304/a9dd6965-afce-4ff3-85d8-ff0c1baa7298">
|
||||||
|
2. 然后你将在登录页看到一个名为 CAS Login 的按钮
|
||||||
|
3. 完成
|
12
packages/plugins/cas/README.zh-CN.md
Normal file
12
packages/plugins/cas/README.zh-CN.md
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# @nocobase/plugin-cas
|
||||||
|
|
||||||
|
提供 CAS 统一登入方式
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
> 首先你要开启此插件
|
||||||
|
|
||||||
|
1. 在 Authentication 中添加一个 Auth Type 为 CAS 类型的登录方式
|
||||||
|
<img src="https://github.com/nocobase/nocobase/assets/29533304/a9dd6965-afce-4ff3-85d8-ff0c1baa7298">
|
||||||
|
2. 然后你将在登录页看到一个名为 CAS Login 的按钮
|
||||||
|
3. 完成
|
2
packages/plugins/cas/client.d.ts
vendored
Executable file
2
packages/plugins/cas/client.d.ts
vendored
Executable file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './dist/client';
|
||||||
|
export { default } from './dist/client';
|
1
packages/plugins/cas/client.js
Executable file
1
packages/plugins/cas/client.js
Executable file
@ -0,0 +1 @@
|
|||||||
|
module.exports = require('./dist/client/index.js');
|
22
packages/plugins/cas/package.json
Normal file
22
packages/plugins/cas/package.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "@nocobase/plugin-cas",
|
||||||
|
"displayName": "SSO login - CAS",
|
||||||
|
"displayName.zh-CN": "SSO 登录 - CAS",
|
||||||
|
"description": "Unified identity authentication.",
|
||||||
|
"description.zh-CN": "提供CAS登录功能",
|
||||||
|
"version": "0.13.0-alpha.4",
|
||||||
|
"license": "AGPL-3.0",
|
||||||
|
"main": "./dist/server/index.js",
|
||||||
|
"devDependencies": {
|
||||||
|
"@nocobase/actions": "0.13.0-alpha.4",
|
||||||
|
"@nocobase/client": "0.13.0-alpha.4",
|
||||||
|
"@nocobase/database": "0.13.0-alpha.4",
|
||||||
|
"@nocobase/server": "0.13.0-alpha.4",
|
||||||
|
"@nocobase/test": "0.13.0-alpha.4"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"antd": "5.x",
|
||||||
|
"@ant-design/icons": "^5.1.4",
|
||||||
|
"react-router-dom": "^6.11.2"
|
||||||
|
}
|
||||||
|
}
|
2
packages/plugins/cas/server.d.ts
vendored
Executable file
2
packages/plugins/cas/server.d.ts
vendored
Executable file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './dist/server';
|
||||||
|
export { default } from './dist/server';
|
1
packages/plugins/cas/server.js
Executable file
1
packages/plugins/cas/server.js
Executable file
@ -0,0 +1 @@
|
|||||||
|
module.exports = require('./dist/server/index.js');
|
42
packages/plugins/cas/src/client/Options.tsx
Normal file
42
packages/plugins/cas/src/client/Options.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { SchemaComponent } from '@nocobase/client';
|
||||||
|
import React from 'react';
|
||||||
|
import { Space } from 'antd';
|
||||||
|
import { useAuthTranslation, generateNTemplate } from './locale';
|
||||||
|
|
||||||
|
export const Options = () => {
|
||||||
|
const { t } = useAuthTranslation();
|
||||||
|
return (
|
||||||
|
<SchemaComponent
|
||||||
|
scope={{ t }}
|
||||||
|
components={{ Space }}
|
||||||
|
schema={{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
autoSignup: {
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
type: 'boolean',
|
||||||
|
title: '{{t("Sign up automatically when the user does not exist")}}',
|
||||||
|
'x-component': 'Checkbox',
|
||||||
|
},
|
||||||
|
casUrl: {
|
||||||
|
title: '{{t("CAS URL")}}',
|
||||||
|
'x-component': 'Input',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
serviceDomain: {
|
||||||
|
title: '{{t("Service domain")}}',
|
||||||
|
'x-component': 'Input',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-decorator-props': {
|
||||||
|
tooltip: generateNTemplate(
|
||||||
|
'The domain is usually the address of your server, in local development, you can use the address of your local machine, such as: http://localhost:13000',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
47
packages/plugins/cas/src/client/SigninPage.tsx
Normal file
47
packages/plugins/cas/src/client/SigninPage.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { Authenticator, useAPIClient, useRedirect, useCurrentUserContext } from '@nocobase/client';
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { LoginOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Space, App } from 'antd';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
export const SigninPage = (props: { authenticator: Authenticator }) => {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const api = useAPIClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const redirect = useRedirect();
|
||||||
|
const location = useLocation();
|
||||||
|
const { refreshAsync } = useCurrentUserContext();
|
||||||
|
|
||||||
|
const authenticator = props.authenticator;
|
||||||
|
|
||||||
|
const login = async () => {
|
||||||
|
window.location.replace(`/api/cas:login?authenticator=${authenticator.name}`);
|
||||||
|
redirect();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const usp = new URLSearchParams(location.search);
|
||||||
|
if (usp.get('authenticator') === authenticator.name) {
|
||||||
|
api.auth
|
||||||
|
.signIn({}, authenticator.name)
|
||||||
|
.then(async () => {
|
||||||
|
await refreshAsync();
|
||||||
|
redirect();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
navigate({
|
||||||
|
pathname: location.pathname,
|
||||||
|
});
|
||||||
|
message.error(error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [location.search, authenticator.name]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" style={{ display: 'flex' }}>
|
||||||
|
<Button shape="round" block icon={<LoginOutlined />} onClick={login}>
|
||||||
|
{authenticator.title}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
};
|
25
packages/plugins/cas/src/client/index.tsx
Normal file
25
packages/plugins/cas/src/client/index.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { OptionsComponentProvider, SigninPageExtensionProvider, SignupPageProvider } from '@nocobase/client';
|
||||||
|
import React from 'react';
|
||||||
|
import { Plugin } from '@nocobase/client';
|
||||||
|
|
||||||
|
import { SigninPage } from './SigninPage';
|
||||||
|
import { Options } from './Options';
|
||||||
|
import { authType } from '../constants';
|
||||||
|
|
||||||
|
export function CASProvider(props) {
|
||||||
|
return (
|
||||||
|
<OptionsComponentProvider authType={authType} component={Options}>
|
||||||
|
<SigninPageExtensionProvider authType={authType} component={SigninPage}>
|
||||||
|
{props.children}
|
||||||
|
</SigninPageExtensionProvider>
|
||||||
|
</OptionsComponentProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SamlPlugin extends Plugin {
|
||||||
|
async load() {
|
||||||
|
this.app.use(CASProvider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SamlPlugin;
|
11
packages/plugins/cas/src/client/locale/index.ts
Normal file
11
packages/plugins/cas/src/client/locale/index.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
export const NAMESPACE = 'cas';
|
||||||
|
|
||||||
|
export function useAuthTranslation() {
|
||||||
|
return useTranslation(NAMESPACE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateNTemplate(key: string) {
|
||||||
|
return `{{t('${key}', { ns: '${NAMESPACE}', nsMode: 'fallback' })}}`;
|
||||||
|
}
|
4
packages/plugins/cas/src/constants.ts
Normal file
4
packages/plugins/cas/src/constants.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export const authType = 'CAS';
|
||||||
|
export const COOKIE_KEY_TICKET = '_cas_ticket_';
|
||||||
|
export const COOKIE_KEY_AUTHENTICATOR = '_cas_authenticator_';
|
||||||
|
export const namespace = 'cas';
|
1
packages/plugins/cas/src/index.ts
Normal file
1
packages/plugins/cas/src/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from './server';
|
9
packages/plugins/cas/src/locale/zh-CN.ts
Normal file
9
packages/plugins/cas/src/locale/zh-CN.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
const locale = {
|
||||||
|
'Sign in': '统一身份登录',
|
||||||
|
'User will be registered automatically if not exists.': '用户不存在时将自动注册。',
|
||||||
|
'Sign up automatically when the user does not exist': '用户不存在时自动注册',
|
||||||
|
'Service domain': '服务域名',
|
||||||
|
'The domain is usually the address of your server, in local development, you can use the address of your local machine, such as: http://localhost:13000': '域名通常是你的服务器地址,在本地开发时,可以使用你本机的地址,例如:http://localhost:13000',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default locale;
|
14
packages/plugins/cas/src/server/actions/login.ts
Normal file
14
packages/plugins/cas/src/server/actions/login.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Context, Next } from '@nocobase/actions';
|
||||||
|
import { CASAuth } from '../auth';
|
||||||
|
import { COOKIE_KEY_AUTHENTICATOR } from '../../constants';
|
||||||
|
|
||||||
|
export const login = async (ctx: Context, next: Next) => {
|
||||||
|
const { authenticator } = ctx.action.params;
|
||||||
|
ctx.cookies.set(COOKIE_KEY_AUTHENTICATOR, authenticator, {
|
||||||
|
httpOnly: true,
|
||||||
|
});
|
||||||
|
const auth = (await ctx.app.authManager.get(authenticator, ctx)) as CASAuth;
|
||||||
|
const { casUrl, serviceUrl } = auth.getOptions();
|
||||||
|
ctx.redirect(`${casUrl}/login?service=${serviceUrl}`);
|
||||||
|
next();
|
||||||
|
};
|
11
packages/plugins/cas/src/server/actions/service.ts
Normal file
11
packages/plugins/cas/src/server/actions/service.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Context, Next } from '@nocobase/actions';
|
||||||
|
import { COOKIE_KEY_AUTHENTICATOR, COOKIE_KEY_TICKET } from '../../constants';
|
||||||
|
|
||||||
|
export const service = async (ctx: Context, next: Next) => {
|
||||||
|
const { params } = ctx.action;
|
||||||
|
ctx.cookies.set(COOKIE_KEY_TICKET, params.ticket, {
|
||||||
|
httpOnly: true,
|
||||||
|
});
|
||||||
|
ctx.redirect(`/signin?authenticator=${ctx.cookies.get(COOKIE_KEY_AUTHENTICATOR)}`);
|
||||||
|
return next();
|
||||||
|
};
|
75
packages/plugins/cas/src/server/auth.ts
Normal file
75
packages/plugins/cas/src/server/auth.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { AuthConfig, BaseAuth } from '@nocobase/auth';
|
||||||
|
import { Model } from '@nocobase/database';
|
||||||
|
import { AuthModel } from '@nocobase/plugin-auth';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { COOKIE_KEY_AUTHENTICATOR, COOKIE_KEY_TICKET } from '../constants';
|
||||||
|
|
||||||
|
export class CASAuth extends BaseAuth {
|
||||||
|
constructor(config: AuthConfig) {
|
||||||
|
const { ctx } = config;
|
||||||
|
super({
|
||||||
|
...config,
|
||||||
|
userCollection: ctx.db.getCollection('users'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async signOut() {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
ctx.cookies.set(COOKIE_KEY_TICKET, '');
|
||||||
|
ctx.cookies.set(COOKIE_KEY_AUTHENTICATOR, '');
|
||||||
|
await super.signOut();
|
||||||
|
}
|
||||||
|
|
||||||
|
getOptions() {
|
||||||
|
const opts = this.options || {};
|
||||||
|
return {
|
||||||
|
...opts,
|
||||||
|
serviceUrl: `${opts.serviceDomain}/api/cas:service`,
|
||||||
|
} as {
|
||||||
|
casUrl?: string;
|
||||||
|
serviceUrl?: string;
|
||||||
|
autoSignup?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceValidate(ticket) {
|
||||||
|
const { casUrl, serviceUrl } = this.getOptions();
|
||||||
|
const url = `${casUrl}/serviceValidate?ticket=${ticket}&service=${serviceUrl}`;
|
||||||
|
return axios.get(url).catch((err) => {
|
||||||
|
throw new Error('CSA serviceValidate error: ' + err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate() {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
let user: Model;
|
||||||
|
const { autoSignup } = this.getOptions();
|
||||||
|
const ticket = ctx.cookies.get(COOKIE_KEY_TICKET);
|
||||||
|
const res = ticket ? await this.serviceValidate(ticket) : null;
|
||||||
|
const pattern = /<(?:cas|sso):user>(.*?)<\/(?:cas|sso):user>/;
|
||||||
|
const nickname = res?.data.match(pattern)?.[1];
|
||||||
|
if (nickname) {
|
||||||
|
const userRepo = this.userCollection.repository;
|
||||||
|
user = await userRepo.findOne({
|
||||||
|
filter: { nickname },
|
||||||
|
});
|
||||||
|
if (user) {
|
||||||
|
await this.authenticator.addUser(user, {
|
||||||
|
through: {
|
||||||
|
uuid: nickname,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// New data
|
||||||
|
const authenticator = this.authenticator as AuthModel;
|
||||||
|
if (autoSignup) {
|
||||||
|
user = await authenticator.findOrCreateUser(nickname, {
|
||||||
|
nickname: nickname,
|
||||||
|
});
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
1
packages/plugins/cas/src/server/index.ts
Normal file
1
packages/plugins/cas/src/server/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from './plugin';
|
38
packages/plugins/cas/src/server/plugin.ts
Normal file
38
packages/plugins/cas/src/server/plugin.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { InstallOptions, Plugin } from '@nocobase/server';
|
||||||
|
import { service } from './actions/service';
|
||||||
|
import { authType } from '../constants';
|
||||||
|
import { CASAuth } from './auth';
|
||||||
|
import { login } from './actions/login';
|
||||||
|
|
||||||
|
export class CasPlugin extends Plugin {
|
||||||
|
afterAdd() {}
|
||||||
|
|
||||||
|
beforeLoad() {}
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
this.app.authManager.registerTypes(authType, {
|
||||||
|
auth: CASAuth,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.resource({
|
||||||
|
name: 'cas',
|
||||||
|
actions: {
|
||||||
|
service,
|
||||||
|
login,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.app.acl.allow('cas', '*', 'public');
|
||||||
|
}
|
||||||
|
|
||||||
|
async install(options?: InstallOptions) {}
|
||||||
|
|
||||||
|
async afterEnable() {}
|
||||||
|
|
||||||
|
async afterDisable() {}
|
||||||
|
|
||||||
|
async remove() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CasPlugin;
|
||||||
|
//
|
@ -32,6 +32,7 @@
|
|||||||
"@nocobase/plugin-multi-app-share-collection": "0.13.0-alpha.4",
|
"@nocobase/plugin-multi-app-share-collection": "0.13.0-alpha.4",
|
||||||
"@nocobase/plugin-oidc": "0.13.0-alpha.4",
|
"@nocobase/plugin-oidc": "0.13.0-alpha.4",
|
||||||
"@nocobase/plugin-saml": "0.13.0-alpha.4",
|
"@nocobase/plugin-saml": "0.13.0-alpha.4",
|
||||||
|
"@nocobase/plugin-cas": "0.13.0-alpha.4",
|
||||||
"@nocobase/plugin-sample-hello": "0.13.0-alpha.4",
|
"@nocobase/plugin-sample-hello": "0.13.0-alpha.4",
|
||||||
"@nocobase/plugin-sequence-field": "0.13.0-alpha.4",
|
"@nocobase/plugin-sequence-field": "0.13.0-alpha.4",
|
||||||
"@nocobase/plugin-sms-auth": "0.13.0-alpha.4",
|
"@nocobase/plugin-sms-auth": "0.13.0-alpha.4",
|
||||||
|
@ -35,6 +35,7 @@ export class PresetNocoBase extends Plugin {
|
|||||||
'multi-app-share-collection',
|
'multi-app-share-collection',
|
||||||
'oidc',
|
'oidc',
|
||||||
'saml',
|
'saml',
|
||||||
|
'cas',
|
||||||
'map',
|
'map',
|
||||||
'snapshot-field',
|
'snapshot-field',
|
||||||
'graph-collection-manager',
|
'graph-collection-manager',
|
||||||
|
Loading…
Reference in New Issue
Block a user