diff --git a/packages/plugins/cas/README.md b/packages/plugins/cas/README.md
new file mode 100644
index 0000000000..ec11906244
--- /dev/null
+++ b/packages/plugins/cas/README.md
@@ -0,0 +1,12 @@
+# @nocobase/plugin-cas
+
+提供 CAS 统一登入方式
+
+## 使用方法
+
+> 首先你要开启此插件
+
+1. 在 Authentication 中添加一个 Auth Type 为 CAS 类型的登录方式
+
+2. 然后你将在登录页看到一个名为 CAS Login 的按钮
+3. 完成
diff --git a/packages/plugins/cas/README.zh-CN.md b/packages/plugins/cas/README.zh-CN.md
new file mode 100644
index 0000000000..ec11906244
--- /dev/null
+++ b/packages/plugins/cas/README.zh-CN.md
@@ -0,0 +1,12 @@
+# @nocobase/plugin-cas
+
+提供 CAS 统一登入方式
+
+## 使用方法
+
+> 首先你要开启此插件
+
+1. 在 Authentication 中添加一个 Auth Type 为 CAS 类型的登录方式
+
+2. 然后你将在登录页看到一个名为 CAS Login 的按钮
+3. 完成
diff --git a/packages/plugins/cas/client.d.ts b/packages/plugins/cas/client.d.ts
new file mode 100755
index 0000000000..6c459cbac4
--- /dev/null
+++ b/packages/plugins/cas/client.d.ts
@@ -0,0 +1,2 @@
+export * from './dist/client';
+export { default } from './dist/client';
diff --git a/packages/plugins/cas/client.js b/packages/plugins/cas/client.js
new file mode 100755
index 0000000000..b6e3be70e6
--- /dev/null
+++ b/packages/plugins/cas/client.js
@@ -0,0 +1 @@
+module.exports = require('./dist/client/index.js');
diff --git a/packages/plugins/cas/package.json b/packages/plugins/cas/package.json
new file mode 100644
index 0000000000..2e6ade641c
--- /dev/null
+++ b/packages/plugins/cas/package.json
@@ -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"
+ }
+}
diff --git a/packages/plugins/cas/server.d.ts b/packages/plugins/cas/server.d.ts
new file mode 100755
index 0000000000..c41081ddc6
--- /dev/null
+++ b/packages/plugins/cas/server.d.ts
@@ -0,0 +1,2 @@
+export * from './dist/server';
+export { default } from './dist/server';
diff --git a/packages/plugins/cas/server.js b/packages/plugins/cas/server.js
new file mode 100755
index 0000000000..972842039a
--- /dev/null
+++ b/packages/plugins/cas/server.js
@@ -0,0 +1 @@
+module.exports = require('./dist/server/index.js');
diff --git a/packages/plugins/cas/src/client/Options.tsx b/packages/plugins/cas/src/client/Options.tsx
new file mode 100644
index 0000000000..5baf24705f
--- /dev/null
+++ b/packages/plugins/cas/src/client/Options.tsx
@@ -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 (
+
+ );
+};
diff --git a/packages/plugins/cas/src/client/SigninPage.tsx b/packages/plugins/cas/src/client/SigninPage.tsx
new file mode 100644
index 0000000000..ce58c8c195
--- /dev/null
+++ b/packages/plugins/cas/src/client/SigninPage.tsx
@@ -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 (
+
+ } onClick={login}>
+ {authenticator.title}
+
+
+ );
+};
diff --git a/packages/plugins/cas/src/client/index.tsx b/packages/plugins/cas/src/client/index.tsx
new file mode 100644
index 0000000000..84d0f9a69b
--- /dev/null
+++ b/packages/plugins/cas/src/client/index.tsx
@@ -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 (
+
+
+ {props.children}
+
+
+ );
+}
+
+export class SamlPlugin extends Plugin {
+ async load() {
+ this.app.use(CASProvider);
+ }
+}
+
+export default SamlPlugin;
diff --git a/packages/plugins/cas/src/client/locale/index.ts b/packages/plugins/cas/src/client/locale/index.ts
new file mode 100644
index 0000000000..153a97e091
--- /dev/null
+++ b/packages/plugins/cas/src/client/locale/index.ts
@@ -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' })}}`;
+}
diff --git a/packages/plugins/cas/src/constants.ts b/packages/plugins/cas/src/constants.ts
new file mode 100644
index 0000000000..b81da2e1a1
--- /dev/null
+++ b/packages/plugins/cas/src/constants.ts
@@ -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';
diff --git a/packages/plugins/cas/src/index.ts b/packages/plugins/cas/src/index.ts
new file mode 100644
index 0000000000..7ddad58145
--- /dev/null
+++ b/packages/plugins/cas/src/index.ts
@@ -0,0 +1 @@
+export { default } from './server';
diff --git a/packages/plugins/cas/src/locale/zh-CN.ts b/packages/plugins/cas/src/locale/zh-CN.ts
new file mode 100644
index 0000000000..a570dd7c66
--- /dev/null
+++ b/packages/plugins/cas/src/locale/zh-CN.ts
@@ -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;
diff --git a/packages/plugins/cas/src/server/actions/login.ts b/packages/plugins/cas/src/server/actions/login.ts
new file mode 100644
index 0000000000..d414e09eb6
--- /dev/null
+++ b/packages/plugins/cas/src/server/actions/login.ts
@@ -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();
+};
diff --git a/packages/plugins/cas/src/server/actions/service.ts b/packages/plugins/cas/src/server/actions/service.ts
new file mode 100644
index 0000000000..8400a01f2d
--- /dev/null
+++ b/packages/plugins/cas/src/server/actions/service.ts
@@ -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();
+};
diff --git a/packages/plugins/cas/src/server/auth.ts b/packages/plugins/cas/src/server/auth.ts
new file mode 100644
index 0000000000..b64f38dcc1
--- /dev/null
+++ b/packages/plugins/cas/src/server/auth.ts
@@ -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;
+ }
+}
diff --git a/packages/plugins/cas/src/server/collections/.gitkeep b/packages/plugins/cas/src/server/collections/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/plugins/cas/src/server/index.ts b/packages/plugins/cas/src/server/index.ts
new file mode 100644
index 0000000000..b68aea57f9
--- /dev/null
+++ b/packages/plugins/cas/src/server/index.ts
@@ -0,0 +1 @@
+export { default } from './plugin';
diff --git a/packages/plugins/cas/src/server/plugin.ts b/packages/plugins/cas/src/server/plugin.ts
new file mode 100644
index 0000000000..21fc269383
--- /dev/null
+++ b/packages/plugins/cas/src/server/plugin.ts
@@ -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;
+//
diff --git a/packages/presets/nocobase/package.json b/packages/presets/nocobase/package.json
index ded6d494ee..3708452852 100644
--- a/packages/presets/nocobase/package.json
+++ b/packages/presets/nocobase/package.json
@@ -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",
diff --git a/packages/presets/nocobase/src/server/index.ts b/packages/presets/nocobase/src/server/index.ts
index f0a239364d..abbd9d08ff 100644
--- a/packages/presets/nocobase/src/server/index.ts
+++ b/packages/presets/nocobase/src/server/index.ts
@@ -35,6 +35,7 @@ export class PresetNocoBase extends Plugin {
'multi-app-share-collection',
'oidc',
'saml',
+ 'cas',
'map',
'snapshot-field',
'graph-collection-manager',