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:
Dunqing 2023-09-02 19:40:04 +08:00 committed by GitHub
parent e7d60389b9
commit 24179c4469
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 332 additions and 0 deletions

View 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. 完成

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

@ -0,0 +1,2 @@
export * from './dist/client';
export { default } from './dist/client';

1
packages/plugins/cas/client.js Executable file
View File

@ -0,0 +1 @@
module.exports = require('./dist/client/index.js');

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

@ -0,0 +1,2 @@
export * from './dist/server';
export { default } from './dist/server';

1
packages/plugins/cas/server.js Executable file
View File

@ -0,0 +1 @@
module.exports = require('./dist/server/index.js');

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

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

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

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

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

View File

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

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

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

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

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

View File

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

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

View File

@ -32,6 +32,7 @@
"@nocobase/plugin-multi-app-share-collection": "0.13.0-alpha.4",
"@nocobase/plugin-oidc": "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-sequence-field": "0.13.0-alpha.4",
"@nocobase/plugin-sms-auth": "0.13.0-alpha.4",

View File

@ -35,6 +35,7 @@ export class PresetNocoBase extends Plugin {
'multi-app-share-collection',
'oidc',
'saml',
'cas',
'map',
'snapshot-field',
'graph-collection-manager',