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 ( + + + + ); +}; 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',