From 7d29c717eb03dce9280602137c518790e7085e62 Mon Sep 17 00:00:00 2001 From: YANG QIA <2013xile@gmail.com> Date: Thu, 11 Jan 2024 11:27:40 +0800 Subject: [PATCH] feat(oidc): add advanced options (#3364) * fix(oidc): params issue * fix: test * fix: exchangeBody * feat: add advanced settings * fix: locale * fix: bug * feat: allow to set state token manually --- .../plugin-oidc/src/client/Options.tsx | 437 ++++++++++++------ .../plugin-oidc/src/locale/en-US.json | 17 +- .../plugin-oidc/src/locale/zh-CN.json | 17 +- .../src/server/__tests__/oidc.test.ts | 2 +- .../src/server/actions/getAuthUrl.ts | 6 +- .../src/server/actions/redirect.ts | 3 +- .../plugin-oidc/src/server/oidc-auth.ts | 43 +- 7 files changed, 373 insertions(+), 152 deletions(-) diff --git a/packages/plugins/@nocobase/plugin-oidc/src/client/Options.tsx b/packages/plugins/@nocobase/plugin-oidc/src/client/Options.tsx index 391615f19..cbc736807 100644 --- a/packages/plugins/@nocobase/plugin-oidc/src/client/Options.tsx +++ b/packages/plugins/@nocobase/plugin-oidc/src/client/Options.tsx @@ -1,5 +1,5 @@ import { CopyOutlined } from '@ant-design/icons'; -import { ArrayItems } from '@formily/antd-v5'; +import { ArrayItems, FormTab } from '@formily/antd-v5'; import { observer } from '@formily/react'; import { FormItem, Input, SchemaComponent } from '@nocobase/client'; import { Card, Space, message } from 'antd'; @@ -9,143 +9,6 @@ import { lang, useOidcTranslation } from './locale'; const schema = { type: 'object', properties: { - oidc: { - type: 'object', - properties: { - issuer: { - type: 'string', - title: '{{t("Issuer")}}', - 'x-component': 'Input', - 'x-decorator': 'FormItem', - required: true, - }, - clientId: { - type: 'string', - title: '{{t("Client ID")}}', - 'x-component': 'Input', - 'x-decorator': 'FormItem', - required: true, - }, - clientSecret: { - type: 'string', - title: '{{t("Client Secret")}}', - 'x-component': 'Input', - 'x-decorator': 'FormItem', - required: true, - }, - scope: { - type: 'string', - title: '{{t("scope")}}', - 'x-component': 'Input', - 'x-decorator': 'FormItem', - 'x-decorator-props': { - tooltip: '{{t("Default: openid profile email")}}', - }, - }, - idTokenSignedResponseAlg: { - type: 'string', - title: '{{t("id_token signed response algorithm")}}', - 'x-component': 'Select', - 'x-decorator': 'FormItem', - enum: [ - { label: 'HS256', value: 'HS256' }, - { label: 'HS384', value: 'HS384' }, - { label: 'HS512', value: 'HS512' }, - { label: 'RS256', value: 'RS256' }, - { label: 'RS384', value: 'RS384' }, - { label: 'RS512', value: 'RS512' }, - { label: 'ES256', value: 'ES256' }, - { label: 'ES384', value: 'ES384' }, - { label: 'ES512', value: 'ES512' }, - { label: 'PS256', value: 'PS256' }, - { label: 'PS384', value: 'PS384' }, - { label: 'PS512', value: 'PS512' }, - ], - }, - http: { - type: 'boolean', - title: '{{t("HTTP")}}', - 'x-component': 'Checkbox', - 'x-decorator': 'FormItem', - }, - port: { - type: 'number', - title: '{{t("Port")}}', - 'x-component': 'InputNumber', - 'x-decorator': 'FormItem', - 'x-component-props': { - style: { - width: '15%', - minWidth: '100px', - }, - }, - }, - fieldMap: { - title: '{{t("Field Map")}}', - type: 'array', - 'x-decorator': 'FormItem', - 'x-component': 'ArrayItems', - items: { - type: 'object', - 'x-decorator': 'ArrayItems.Item', - properties: { - space: { - type: 'void', - 'x-component': 'Space', - properties: { - source: { - type: 'string', - 'x-decorator': 'FormItem', - 'x-component': 'Input', - 'x-component-props': { - placeholder: '{{t("source")}}', - }, - }, - target: { - type: 'string', - 'x-decorator': 'FormItem', - 'x-component': 'Select', - 'x-component-props': { - placeholder: '{{t("target")}}', - }, - enum: [ - { label: lang('Nickname'), value: 'nickname' }, - { label: lang('Email'), value: 'email' }, - { label: lang('Phone'), value: 'phone' }, - { label: lang('Username'), value: 'username' }, - ], - }, - remove: { - type: 'void', - 'x-decorator': 'FormItem', - 'x-component': 'ArrayItems.Remove', - }, - }, - }, - }, - }, - properties: { - add: { - type: 'void', - title: 'Add', - 'x-component': 'ArrayItems.Addition', - }, - }, - }, - userBindField: { - type: 'string', - title: '{{t("Use this field to bind the user")}}', - 'x-component': 'Select', - 'x-decorator': 'FormItem', - default: 'email', - enum: [ - { label: lang('Email'), value: 'email' }, - { label: lang('Username'), value: 'username' }, - ], - required: true, - }, - }, - }, public: { type: 'object', properties: { @@ -158,6 +21,302 @@ const schema = { }, }, }, + oidc: { + type: 'object', + properties: { + collapse: { + type: 'void', + 'x-component': 'FormTab', + properties: { + basic: { + type: 'void', + 'x-component': 'FormTab.TabPane', + 'x-component-props': { + tab: lang('Basic configuration'), + }, + properties: { + issuer: { + type: 'string', + title: '{{t("Issuer")}}', + 'x-component': 'Input', + 'x-decorator': 'FormItem', + required: true, + }, + clientId: { + type: 'string', + title: '{{t("Client ID")}}', + 'x-component': 'Input', + 'x-decorator': 'FormItem', + required: true, + }, + clientSecret: { + type: 'string', + title: '{{t("Client Secret")}}', + 'x-component': 'Input', + 'x-decorator': 'FormItem', + required: true, + }, + scope: { + type: 'string', + title: '{{t("scope")}}', + 'x-component': 'Input', + 'x-decorator': 'FormItem', + 'x-decorator-props': { + tooltip: '{{t("Default: openid profile email")}}', + }, + }, + idTokenSignedResponseAlg: { + type: 'string', + title: '{{t("id_token signed response algorithm")}}', + 'x-component': 'Select', + 'x-decorator': 'FormItem', + enum: [ + { label: 'HS256', value: 'HS256' }, + { label: 'HS384', value: 'HS384' }, + { label: 'HS512', value: 'HS512' }, + { label: 'RS256', value: 'RS256' }, + { label: 'RS384', value: 'RS384' }, + { label: 'RS512', value: 'RS512' }, + { label: 'ES256', value: 'ES256' }, + { label: 'ES384', value: 'ES384' }, + { label: 'ES512', value: 'ES512' }, + { label: 'PS256', value: 'PS256' }, + { label: 'PS384', value: 'PS384' }, + { label: 'PS512', value: 'PS512' }, + ], + }, + }, + }, + mapping: { + type: 'void', + 'x-component': 'FormTab.TabPane', + 'x-component-props': { + tab: lang('Field mapping'), + }, + properties: { + fieldMap: { + title: '{{t("Field Map")}}', + type: 'array', + 'x-decorator': 'FormItem', + 'x-component': 'ArrayItems', + items: { + type: 'object', + 'x-decorator': 'ArrayItems.Item', + properties: { + space: { + type: 'void', + 'x-component': 'Space', + properties: { + source: { + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + 'x-component-props': { + placeholder: '{{t("source")}}', + }, + }, + target: { + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'Select', + 'x-component-props': { + placeholder: '{{t("target")}}', + }, + enum: [ + { label: lang('Nickname'), value: 'nickname' }, + { label: lang('Email'), value: 'email' }, + { label: lang('Phone'), value: 'phone' }, + { label: lang('Username'), value: 'username' }, + ], + }, + remove: { + type: 'void', + 'x-decorator': 'FormItem', + 'x-component': 'ArrayItems.Remove', + }, + }, + }, + }, + }, + properties: { + add: { + type: 'void', + title: 'Add', + 'x-component': 'ArrayItems.Addition', + }, + }, + }, + userBindField: { + type: 'string', + title: '{{t("Use this field to bind the user")}}', + 'x-component': 'Select', + 'x-decorator': 'FormItem', + default: 'email', + enum: [ + { label: lang('Email'), value: 'email' }, + { label: lang('Username'), value: 'username' }, + ], + required: true, + }, + }, + }, + advanced: { + type: 'void', + 'x-component': 'FormTab.TabPane', + 'x-component-props': { + tab: lang('Advanced configuration'), + }, + properties: { + http: { + type: 'boolean', + title: '{{t("HTTP")}}', + 'x-component': 'Checkbox', + 'x-decorator': 'FormItem', + 'x-decorator-props': { + tooltip: '{{t("Check if NocoBase is running on HTTP protocol")}}', + }, + }, + port: { + type: 'number', + title: '{{t("Port")}}', + 'x-component': 'InputNumber', + 'x-decorator': 'FormItem', + 'x-decorator-props': { + tooltip: '{{t("The port number of the NocoBase service if it is not 80 or 443")}}', + }, + 'x-component-props': { + style: { + width: '15%', + minWidth: '100px', + }, + }, + }, + stateToken: { + type: 'string', + title: '{{t("State token")}}', + 'x-component': 'Input', + 'x-decorator': 'FormItem', + description: lang( + "The state token helps prevent CSRF attacks. It's recommended to leave it blank for automatic random generation.", + ), + }, + exchangeBodyKeys: { + type: 'array', + title: '{{t("Pass parameters in the authorization code grant exchange")}}', + 'x-decorator': 'FormItem', + 'x-component': 'ArrayItems', + default: [ + { paramName: '', optionsKey: 'clientId' }, + { + paramName: '', + optionsKey: 'clientSecret', + }, + ], + items: { + type: 'object', + 'x-decorator': 'ArrayItems.Item', + properties: { + space: { + type: 'void', + 'x-component': 'Space', + properties: { + enabled: { + type: 'boolean', + 'x-decorator': 'FormItem', + 'x-component': 'Checkbox', + }, + optionsKey: { + type: 'string', + 'x-decorator': 'FormItem', + 'x-decorator-props': { + style: { + width: '100px', + }, + }, + 'x-component': 'Select', + 'x-read-pretty': true, + enum: [ + { label: lang('Client ID'), value: 'clientId' }, + { label: lang('Client Secret'), value: 'clientSecret' }, + ], + }, + paramName: { + type: 'string', + 'x-decorator': 'FormItem', + 'x-component': 'Input', + 'x-component-props': { + placeholder: '{{t("Parameter name")}}', + }, + }, + }, + }, + }, + }, + }, + userInfoMethod: { + type: 'string', + title: '{{t("Method to call the user info endpoint")}}', + 'x- decorator': 'FormItem', + 'x-component': 'Radio.Group', + default: 'GET', + enum: [ + { + label: 'GET', + value: 'GET', + }, + { + label: 'POST', + value: 'POST', + }, + ], + 'x-reactions': [ + { + dependencies: ['.accessTokenVia'], + when: '{{$deps[0] === "query"}}', + fulfill: { + state: { + value: 'GET', + }, + }, + }, + { + dependencies: ['.accessTokenVia'], + when: '{{$deps[0] === "body"}}', + fulfill: { + state: { + value: 'POST', + }, + }, + }, + ], + }, + accessTokenVia: { + type: 'string', + title: '{{t("Where to put the access token when calling the user info endpoint")}}', + 'x- decorator': 'FormItem', + 'x-component': 'Radio.Group', + default: 'header', + enum: [ + { + label: lang('Header'), + value: 'header', + }, + { + label: lang('Body (Use with POST method)'), + value: 'body', + }, + { + label: lang('Query parameters (Use with GET method)'), + value: 'query', + }, + ], + }, + }, + }, + }, + }, + }, + }, usage: { type: 'void', 'x-component': 'Usage', @@ -187,5 +346,5 @@ const Usage = observer(() => { export const Options = () => { const { t } = useOidcTranslation(); - return ; + return ; }; diff --git a/packages/plugins/@nocobase/plugin-oidc/src/locale/en-US.json b/packages/plugins/@nocobase/plugin-oidc/src/locale/en-US.json index 706425673..4ce29c41f 100644 --- a/packages/plugins/@nocobase/plugin-oidc/src/locale/en-US.json +++ b/packages/plugins/@nocobase/plugin-oidc/src/locale/en-US.json @@ -21,5 +21,20 @@ "Use this field to bind the user": "Use this field to bind the user", "Sign up automatically when the user does not exist": "Sign up automatically when the user does not exist", "Username must be 2-16 characters in length (excluding @.<>\"'/)": "Username must be 2-16 characters in length (excluding @.<>\"'/)", - "User not found": "User not found" + "User not found": "User not found", + "Basic configuration": "Basic configuration", + "Field mapping": "Field mapping", + "Advanced configuration": "Advanced configuration", + "Usage": "Usage", + "Redirect URL": "Redirect URL", + "Check if NocoBase is running on HTTP protocol": "Check if NocoBase is running on HTTP protocol", + "The port number of the NocoBase service if it is not 80 or 443": "The port number of the NocoBase service if it is not 80 or 443", + "Pass parameters in the authorization code grant exchange": "Pass parameters in the authorization code grant exchange", + "Method to call the user info endpoint": "Method to call the user info endpoint", + "Where to put the access token when calling the user info endpoint": "Where to put the access token when calling the user info endpoint", + "Header": "Header", + "Body (Use with POST method)": "Body (Use with POST method)", + "Query parameters (Use with GET method)": "Query parameters (Use with GET method)", + "Parameter name": "Parameter name", + "The state token helps prevent CSRF attacks. It's recommended to leave it blank for automatic random generation.": "The state token helps prevent CSRF attacks. It's recommended to leave it blank for automatic random generation." } diff --git a/packages/plugins/@nocobase/plugin-oidc/src/locale/zh-CN.json b/packages/plugins/@nocobase/plugin-oidc/src/locale/zh-CN.json index b6bffa34b..ba528657d 100644 --- a/packages/plugins/@nocobase/plugin-oidc/src/locale/zh-CN.json +++ b/packages/plugins/@nocobase/plugin-oidc/src/locale/zh-CN.json @@ -9,5 +9,20 @@ "Use this field to bind the user": "使用此字段绑定用户", "Sign up automatically when the user does not exist": "用户不存在时自动注册", "Username must be 2-16 characters in length (excluding @.<>\"'/)": "用户名必须为2-16个字符并且不包含@.<>\"'/)", - "User not found": "用户不存在" + "User not found": "用户不存在", + "Basic configuration": "基础配置", + "Field mapping": "字段映射", + "Advanced configuration": "高级配置", + "Usage": "使用", + "Redirect URL": "回调 URL", + "Check if NocoBase is running on HTTP protocol": "NocoBase 应用为HTTP协议时勾选", + "The port number of the NocoBase service if it is not 80 or 443": "NocoBase 应用端口,默认 443/80", + "Pass parameters in the authorization code grant exchange": "使用 code 交换 token 时需要传递的参数", + "Method to call the user info endpoint": "访问获取用户信息的 API 的 HTTP 方法", + "Where to put the access token when calling the user info endpoint": "访问获取用户信息的 API 时 access_token 的传递方式", + "Header": "请求头 (Header, 默认)", + "Body (Use with POST method)": "请求体(Body, 配合 POST 方法使用)", + "Query parameters (Use with GET method)": "请求 URL 参数(Query, 配合 GET 方法使用)", + "Parameter name": "参数名", + "The state token helps prevent CSRF attacks. It's recommended to leave it blank for automatic random generation.": "state token 用于防止 CSRF 攻击,建议留空使用自动生成的随机值。" } diff --git a/packages/plugins/@nocobase/plugin-oidc/src/server/__tests__/oidc.test.ts b/packages/plugins/@nocobase/plugin-oidc/src/server/__tests__/oidc.test.ts index c026d9361..3fec2e75b 100644 --- a/packages/plugins/@nocobase/plugin-oidc/src/server/__tests__/oidc.test.ts +++ b/packages/plugins/@nocobase/plugin-oidc/src/server/__tests__/oidc.test.ts @@ -52,7 +52,7 @@ describe('oidc', () => { } as any); const res = await agent.set('X-Authenticator', 'oidc-auth').resource('oidc').getAuthUrl(); expect(res.body.data).toBeDefined(); - const search = new URLSearchParams(res.body.data); + const search = new URLSearchParams(decodeURIComponent(res.body.data)); expect(search.get('token')).toBeDefined(); expect(search.get('name')).toBe('oidc-auth'); expect(res.headers['set-cookie']).toBeDefined(); diff --git a/packages/plugins/@nocobase/plugin-oidc/src/server/actions/getAuthUrl.ts b/packages/plugins/@nocobase/plugin-oidc/src/server/actions/getAuthUrl.ts index 98a749910..67901c204 100644 --- a/packages/plugins/@nocobase/plugin-oidc/src/server/actions/getAuthUrl.ts +++ b/packages/plugins/@nocobase/plugin-oidc/src/server/actions/getAuthUrl.ts @@ -7,8 +7,8 @@ export const getAuthUrl = async (ctx: Context, next: Next) => { const app = ctx.app.name; const auth = ctx.auth as OIDCAuth; const client = await auth.createOIDCClient(); - const { scope } = auth.getOptions(); - const token = nanoid(15); + const { scope, stateToken } = auth.getOptions(); + const token = stateToken || nanoid(15); ctx.cookies.set(cookieName, token, { httpOnly: true, domain: ctx.hostname, @@ -17,7 +17,7 @@ export const getAuthUrl = async (ctx: Context, next: Next) => { response_type: 'code', scope: scope || 'openid email profile', redirect_uri: auth.getRedirectUri(), - state: `token=${token}&name=${ctx.headers['x-authenticator']}&app=${app}`, + state: encodeURIComponent(`token=${token}&name=${ctx.headers['x-authenticator']}&app=${app}`), }); return next(); diff --git a/packages/plugins/@nocobase/plugin-oidc/src/server/actions/redirect.ts b/packages/plugins/@nocobase/plugin-oidc/src/server/actions/redirect.ts index 18cd216ef..fb642bd65 100644 --- a/packages/plugins/@nocobase/plugin-oidc/src/server/actions/redirect.ts +++ b/packages/plugins/@nocobase/plugin-oidc/src/server/actions/redirect.ts @@ -6,7 +6,7 @@ export const redirect = async (ctx: Context, next: Next) => { const { params: { state }, } = ctx.action; - const search = new URLSearchParams(state); + const search = new URLSearchParams(decodeURIComponent(state)); const authenticator = search.get('name'); const appName = search.get('app'); let prefix = ''; @@ -21,6 +21,7 @@ export const redirect = async (ctx: Context, next: Next) => { const { token } = await auth.signIn(); ctx.redirect(`${prefix}/admin?authenticator=${authenticator}&token=${token}`); } catch (error) { + ctx.logger.error('OIDC auth error', { error }); ctx.redirect(`${prefix}/signin?authenticator=${authenticator}&error=${error.message}`); } await next(); diff --git a/packages/plugins/@nocobase/plugin-oidc/src/server/oidc-auth.ts b/packages/plugins/@nocobase/plugin-oidc/src/server/oidc-auth.ts index d8743bf61..5227717bf 100644 --- a/packages/plugins/@nocobase/plugin-oidc/src/server/oidc-auth.ts +++ b/packages/plugins/@nocobase/plugin-oidc/src/server/oidc-auth.ts @@ -24,6 +24,22 @@ export class OIDCAuth extends BaseAuth { return this.options?.oidc || {}; } + getExchangeBody() { + const options = this.getOptions(); + const { exchangeBodyKeys } = options; + if (!exchangeBodyKeys) { + return {}; + } + const body = {}; + exchangeBodyKeys + .filter((item: { enabled: boolean }) => item.enabled) + .forEach((item: { paramName: string; optionsKey: string }) => { + const name = item.paramName || item.optionsKey; + body[name] = options[item.optionsKey]; + }); + return body; + } + mapField(userInfo: { [source: string]: any }) { const { fieldMap } = this.getOptions(); if (!fieldMap) { @@ -51,18 +67,33 @@ export class OIDCAuth extends BaseAuth { async validate() { const ctx = this.ctx; const { params: values } = ctx.action; - const token = ctx.cookies.get(cookieName); - const search = new URLSearchParams(values.state); + const { userInfoMethod = 'GET', accessTokenVia = 'header', stateToken } = this.getOptions(); + const token = stateToken || ctx.cookies.get(cookieName); + const search = new URLSearchParams(decodeURIComponent(values.state)); if (search.get('token') !== token) { ctx.logger.error('nocobase_oidc state mismatch', { method: 'validate' }); return null; } const client = await this.createOIDCClient(); - const tokens = await client.callback(this.getRedirectUri(), { - code: values.code, - iss: values.iss, + const tokens = await client.callback( + this.getRedirectUri(), + { + code: values.code, + iss: values.iss, + }, + {}, + { exchangeBody: this.getExchangeBody() }, + ); + const userInfo: { [key: string]: any } = await client.userinfo(tokens, { + method: userInfoMethod, + via: accessTokenVia !== 'query' ? accessTokenVia : 'header', + params: + accessTokenVia === 'query' + ? { + access_token: tokens.access_token, + } + : {}, }); - const userInfo: { [key: string]: any } = await client.userinfo(tokens.access_token); const mappedUserInfo = this.mapField(userInfo); const { nickname, username, name, sub, email, phone } = mappedUserInfo; const authenticator = this.authenticator as AuthModel;