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
This commit is contained in:
		
							parent
							
								
									4d44c6e24e
								
							
						
					
					
						commit
						7d29c717eb
					
				| @ -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,8 +9,31 @@ import { lang, useOidcTranslation } from './locale'; | ||||
| const schema = { | ||||
|   type: 'object', | ||||
|   properties: { | ||||
|     public: { | ||||
|       type: 'object', | ||||
|       properties: { | ||||
|         autoSignup: { | ||||
|           'x-decorator': 'FormItem', | ||||
|           type: 'boolean', | ||||
|           title: '{{t("Sign up automatically when the user does not exist")}}', | ||||
|           'x-component': 'Checkbox', | ||||
|           default: true, | ||||
|         }, | ||||
|       }, | ||||
|     }, | ||||
|     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', | ||||
| @ -62,24 +85,15 @@ const schema = { | ||||
|                     { 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', | ||||
|             }, | ||||
|             mapping: { | ||||
|               type: 'void', | ||||
|               'x-component': 'FormTab.TabPane', | ||||
|               'x-component-props': { | ||||
|             style: { | ||||
|               width: '15%', | ||||
|               minWidth: '100px', | ||||
|             }, | ||||
|           }, | ||||
|                 tab: lang('Field mapping'), | ||||
|               }, | ||||
|               properties: { | ||||
|                 fieldMap: { | ||||
|                   title: '{{t("Field Map")}}', | ||||
|                   type: 'array', | ||||
| @ -146,15 +160,160 @@ const schema = { | ||||
|                 }, | ||||
|               }, | ||||
|             }, | ||||
|     public: { | ||||
|       type: 'object', | ||||
|             advanced: { | ||||
|               type: 'void', | ||||
|               'x-component': 'FormTab.TabPane', | ||||
|               'x-component-props': { | ||||
|                 tab: lang('Advanced configuration'), | ||||
|               }, | ||||
|               properties: { | ||||
|         autoSignup: { | ||||
|           'x-decorator': 'FormItem', | ||||
|                 http: { | ||||
|                   type: 'boolean', | ||||
|           title: '{{t("Sign up automatically when the user does not exist")}}', | ||||
|                   title: '{{t("HTTP")}}', | ||||
|                   'x-component': 'Checkbox', | ||||
|           default: true, | ||||
|                   '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', | ||||
|                     }, | ||||
|                   ], | ||||
|                 }, | ||||
|               }, | ||||
|             }, | ||||
|           }, | ||||
|         }, | ||||
|       }, | ||||
|     }, | ||||
| @ -187,5 +346,5 @@ const Usage = observer(() => { | ||||
| 
 | ||||
| export const Options = () => { | ||||
|   const { t } = useOidcTranslation(); | ||||
|   return <SchemaComponent scope={{ t }} components={{ Usage, ArrayItems, Space }} schema={schema} />; | ||||
|   return <SchemaComponent scope={{ t }} components={{ Usage, ArrayItems, Space, FormTab }} schema={schema} />; | ||||
| }; | ||||
|  | ||||
| @ -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." | ||||
| } | ||||
|  | ||||
| @ -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 攻击,建议留空使用自动生成的随机值。" | ||||
| } | ||||
|  | ||||
| @ -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(); | ||||
|  | ||||
| @ -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(); | ||||
|  | ||||
| @ -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(); | ||||
|  | ||||
| @ -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(), { | ||||
|     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; | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user