diff --git a/jest.setup.ts b/jest.setup.ts index cecad9c50..09575ea8e 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -2,3 +2,7 @@ import dotenv from 'dotenv'; import path from 'path'; dotenv.config({ path: path.resolve(process.cwd(), '.env.test') }); + +// 当前jest版本低,polyfill +import { TextEncoder, TextDecoder } from 'util'; +Object.assign(global, { TextDecoder, TextEncoder }); diff --git a/packages/app/client/src/plugins/auth.ts b/packages/app/client/src/plugins/auth.ts new file mode 100644 index 000000000..ce68ee0f2 --- /dev/null +++ b/packages/app/client/src/plugins/auth.ts @@ -0,0 +1 @@ +export { default } from '@nocobase/plugin-auth/client'; \ No newline at end of file diff --git a/packages/app/client/src/plugins/sms-auth.ts b/packages/app/client/src/plugins/sms-auth.ts new file mode 100644 index 000000000..7e576f45a --- /dev/null +++ b/packages/app/client/src/plugins/sms-auth.ts @@ -0,0 +1 @@ +export { default } from '@nocobase/plugin-sms-auth/client'; \ No newline at end of file diff --git a/packages/core/auth/.npmignore b/packages/core/auth/.npmignore new file mode 100644 index 000000000..8be3941cd --- /dev/null +++ b/packages/core/auth/.npmignore @@ -0,0 +1,4 @@ +node_modules +*.log +__tests__ +src \ No newline at end of file diff --git a/packages/core/auth/LICENSE b/packages/core/auth/LICENSE new file mode 100644 index 000000000..f49a4e16e --- /dev/null +++ b/packages/core/auth/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/packages/core/auth/package.json b/packages/core/auth/package.json new file mode 100644 index 000000000..a5648a3bc --- /dev/null +++ b/packages/core/auth/package.json @@ -0,0 +1,19 @@ +{ + "name": "@nocobase/auth", + "version": "0.9.4-alpha.2", + "description": "", + "license": "Apache-2.0", + "main": "./lib/index.js", + "types": "./lib/index.d.ts", + "dependencies": { + "@nocobase/database": "0.9.4-alpha.2", + "@nocobase/resourcer": "0.9.4-alpha.2", + "@nocobase/utils": "0.9.4-alpha.2", + "@nocobase/actions": "0.9.4-alpha.2" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nocobase/nocobase.git", + "directory": "packages/auth" + } +} \ No newline at end of file diff --git a/packages/core/auth/src/__tests__/auth-manager.test.ts b/packages/core/auth/src/__tests__/auth-manager.test.ts new file mode 100644 index 000000000..0a943451b --- /dev/null +++ b/packages/core/auth/src/__tests__/auth-manager.test.ts @@ -0,0 +1,50 @@ +import { Context } from '@nocobase/actions'; +import { Auth, AuthManager } from '@nocobase/auth'; +import { Model } from '@nocobase/database'; + +class MockStorer { + elements: Map = new Map(); + async set(name: string, value: any) { + this.elements.set(name, value); + } + + async get(name: string) { + return this.elements.get(name); + } +} + +class BasicAuth extends Auth { + public user: Model; + + async check() { + return null; + } + + async getIdentity() { + return null; + } +} + +describe('auth-manager', () => { + let authManager: AuthManager; + beforeEach(() => { + authManager = new AuthManager({ + authKey: 'X-Authenticator', + }); + + const storer = new MockStorer(); + const authenticator = { + name: 'basic-test', + authType: 'basic', + options: {}, + }; + storer.set(authenticator.name, authenticator); + authManager.setStorer(storer); + }); + + it('should get authenticator', async () => { + authManager.registerTypes('basic', { auth: BasicAuth }); + const authenticator = await authManager.get('basic-test', {} as Context); + expect(authenticator).toBeInstanceOf(BasicAuth); + }); +}); diff --git a/packages/core/auth/src/actions.ts b/packages/core/auth/src/actions.ts new file mode 100644 index 000000000..3d3fc9889 --- /dev/null +++ b/packages/core/auth/src/actions.ts @@ -0,0 +1,20 @@ +import { Handlers } from '@nocobase/resourcer'; + +export const actions = { + signIn: async (ctx, next) => { + ctx.body = await ctx.auth.signIn(); + await next(); + }, + signOut: async (ctx, next) => { + await ctx.auth.signOut(); + await next(); + }, + signUp: async (ctx, next) => { + await ctx.auth.signUp(); + await next(); + }, + check: async (ctx, next) => { + ctx.body = ctx.auth.user || {}; + await next(); + }, +} as Handlers; diff --git a/packages/core/auth/src/auth-manager.ts b/packages/core/auth/src/auth-manager.ts new file mode 100644 index 000000000..1d90b6dbf --- /dev/null +++ b/packages/core/auth/src/auth-manager.ts @@ -0,0 +1,99 @@ +import { Context, Next } from '@nocobase/actions'; +import { Model } from '@nocobase/database'; +import { Registry } from '@nocobase/utils'; +import { Auth, AuthExtend } from './auth'; + +type Storer = { + get: (name: string) => Promise; +}; + +type AuthManagerOptions = { + authKey: string; + default?: string; +}; + +type AuthConfig = { + auth: AuthExtend; // The authentication class. +}; + +export class AuthManager { + protected options: AuthManagerOptions; + protected authTypes: Registry = new Registry(); + // authenticators collection manager. + protected storer: Storer; + + constructor(options: AuthManagerOptions) { + this.options = options; + } + + setStorer(storer: Storer) { + this.storer = storer; + } + + /** + * registerTypes + * @description Add a new authenticate type and the corresponding authenticator. + * The types will show in the authenticators list of the admin panel. + * + * @param {string} authType - The type of the authenticator. It is required to be unique. + * @param {AuthConfig} authConfig - Configurations of the kind of authenticator. + */ + registerTypes(authType: string, authConfig: AuthConfig) { + this.authTypes.register(authType, authConfig); + } + + listTypes() { + return Array.from(this.authTypes.getKeys()); + } + + getAuthConfig(authType: string) { + return this.authTypes.get(authType); + } + + /** + * get + * @description Get authenticator instance by name. + * @param {string} name - The name of the authenticator. + * @return {Promise} authenticator instance. + */ + async get(name: string, ctx: Context) { + if (!this.storer) { + throw new Error('AuthManager.storer is not set.'); + } + const authenticator = await this.storer.get(name); + if (!authenticator) { + throw new Error(`Authenticator [${name}] is not found.`); + } + const { auth } = this.authTypes.get(authenticator.authType); + if (!auth) { + throw new Error(`AuthType [${name}] is not found.`); + } + return new auth({ authenticator, options: authenticator.options, ctx }); + } + + /** + * middleware + * @description Auth middleware, used to check the authentication status. + */ + middleware() { + return async (ctx: Context & { auth: Auth }, next: Next) => { + const name = ctx.get(this.options.authKey) || this.options.default; + let authenticator: Auth; + try { + authenticator = await ctx.app.authManager.get(name, ctx); + ctx.auth = authenticator; + } catch (err) { + ctx.auth = {} as Auth; + ctx.app.logger.warn(`auth, ${err.message}, ${err.stack}`); + return next(); + } + if (authenticator) { + const user = await ctx.auth.check(); + if (user) { + ctx.auth.user = user; + } + } + await next(); + }; + } +} diff --git a/packages/core/auth/src/auth.ts b/packages/core/auth/src/auth.ts new file mode 100644 index 000000000..3f970ebc9 --- /dev/null +++ b/packages/core/auth/src/auth.ts @@ -0,0 +1,44 @@ +import { Context } from '@nocobase/actions'; +import { Model } from '@nocobase/database'; + +export type AuthConfig = { + authenticator: Model; + options: { + [key: string]: any; + }; + ctx: Context; +}; + +export type AuthExtend = new (config: AuthConfig) => T; + +interface IAuth { + user: Model; + // Check the authenticaiton status and return the current user. + check(): Promise; + signIn(): Promise; + signUp(): Promise; + signOut(): Promise; +} + +export abstract class Auth implements IAuth { + abstract user: Model; + protected authenticator: Model; + protected options: { + [key: string]: any; + }; + protected ctx: Context; + + constructor(config: AuthConfig) { + const { authenticator, options, ctx } = config; + this.authenticator = authenticator; + this.options = options; + this.ctx = ctx; + } + + // The abstract methods are required to be implemented by all authentications. + abstract check(); + // The following methods are mainly designed for user authentications. + async signIn(): Promise {} + async signUp(): Promise {} + async signOut(): Promise {} +} diff --git a/packages/core/auth/src/base/auth.ts b/packages/core/auth/src/base/auth.ts new file mode 100644 index 000000000..144d038c7 --- /dev/null +++ b/packages/core/auth/src/base/auth.ts @@ -0,0 +1,74 @@ +import { Auth, AuthConfig } from '../auth'; +import { JwtOptions, JwtService } from './jwt-service'; +import { Collection, Model } from '@nocobase/database'; + +/** + * BaseAuth + * @description A base class with jwt provide some common methods. + */ +export class BaseAuth extends Auth { + protected jwt: JwtService; + protected userCollection: Collection; + + constructor( + config: AuthConfig & { + userCollection: Collection; + }, + ) { + const { options, userCollection } = config; + super(config); + this.userCollection = userCollection; + this.jwt = new JwtService(options.jwt as JwtOptions); + } + + set user(user: Model) { + this.ctx.state.currentUser = user; + } + + get user() { + return this.ctx.state.currentUser; + } + + async check() { + const token = this.ctx.getBearerToken(); + if (!token) { + return null; + } + try { + const { userId } = await this.jwt.decode(token); + const user = await this.userCollection.repository.findOne({ + filter: { + id: userId, + }, + }); + return user; + } catch (err) { + this.ctx.logger.error(err); + return null; + } + } + + async validate(): Promise { + return null; + } + + async signIn() { + let user: Model; + try { + user = await this.validate(); + } catch (err) { + console.log(err); + this.ctx.throw(401, err.message); + } + if (!user) { + this.ctx.throw(401, 'Unauthorized'); + } + const token = this.jwt.sign({ + userId: user.id, + }); + return { + user, + token, + }; + } +} diff --git a/packages/core/auth/src/base/jwt-service.ts b/packages/core/auth/src/base/jwt-service.ts new file mode 100644 index 000000000..4a3aafde0 --- /dev/null +++ b/packages/core/auth/src/base/jwt-service.ts @@ -0,0 +1,40 @@ +import jwt from 'jsonwebtoken'; + +export interface JwtOptions { + secret: string; + expiresIn?: string; +} + +export class JwtService { + constructor(protected options: JwtOptions) { + const { secret, expiresIn } = options || {}; + this.options = { + secret: secret || process.env.APP_KEY, + expiresIn: expiresIn || process.env.JWT_EXPIRES_IN || '7d', + }; + } + + private expiresIn() { + return this.options.expiresIn; + } + + private secret() { + return this.options.secret; + } + + sign(payload: any) { + return jwt.sign(payload, this.secret(), { expiresIn: this.expiresIn() }); + } + + decode(token: string): Promise { + return new Promise((resolve, reject) => { + jwt.verify(token, this.secret(), (err: any, decoded: any) => { + if (err) { + return reject(err); + } + + resolve(decoded); + }); + }); + } +} diff --git a/packages/core/auth/src/index.ts b/packages/core/auth/src/index.ts new file mode 100644 index 000000000..22d986933 --- /dev/null +++ b/packages/core/auth/src/index.ts @@ -0,0 +1,4 @@ +export * from './auth-manager'; +export * from './auth'; +export * from './base/auth'; +export * from './actions'; diff --git a/packages/core/client/src/application/Application.tsx b/packages/core/client/src/application/Application.tsx index c1c8c5df8..03315fd43 100644 --- a/packages/core/client/src/application/Application.tsx +++ b/packages/core/client/src/application/Application.tsx @@ -30,8 +30,8 @@ import { ErrorFallback } from '../schema-component/antd/error-fallback'; import { SchemaInitializerProvider } from '../schema-initializer'; import { BlockTemplateDetails, BlockTemplatePage } from '../schema-templates'; import { SystemSettingsProvider } from '../system-settings'; -import { SigninPage, SignupPage } from '../user'; -import { SigninPageExtensionProvider } from '../user/SigninPageExtension'; +import { SigninPage, SignupPage } from '../auth'; +import { SigninPageExtensionProvider } from '../auth/SigninPageExtension'; import { compose } from './compose'; export interface ApplicationOptions { diff --git a/packages/core/client/src/auth/OptionsComponent.tsx b/packages/core/client/src/auth/OptionsComponent.tsx new file mode 100644 index 000000000..b15f6b76f --- /dev/null +++ b/packages/core/client/src/auth/OptionsComponent.tsx @@ -0,0 +1,23 @@ +import React, { FunctionComponent, createContext, useContext, createElement } from 'react'; +import { useTranslation } from 'react-i18next'; + +const OptionsComponentContext = createContext<{ + [authType: string]: FunctionComponent; +}>({}); + +export const OptionsComponentProvider: React.FC<{ authType: string; component: FunctionComponent }> = (props) => { + const components = useContext(OptionsComponentContext); + components[props.authType] = props.component; + return {props.children}; +}; + +export const useHasOptionsComponent = (authType: string) => { + const components = useContext(OptionsComponentContext); + return components[authType]; +}; + +export const useOptionsComponent = (authType: string) => { + const { t } = useTranslation(); + const component = useHasOptionsComponent(authType); + return component ? createElement(component) : <>; +}; diff --git a/packages/core/client/src/auth/SigninPage.tsx b/packages/core/client/src/auth/SigninPage.tsx new file mode 100644 index 000000000..6694ea623 --- /dev/null +++ b/packages/core/client/src/auth/SigninPage.tsx @@ -0,0 +1,158 @@ +import { Space, Tabs } from 'antd'; +import React, { + useCallback, + useContext, + createContext, + FunctionComponent, + createElement, + useState, + FunctionComponentElement, +} from 'react'; +import { css } from '@emotion/css'; +import { useTranslation } from 'react-i18next'; +import { useHistory, useLocation } from 'react-router-dom'; +import { useAPIClient, useCurrentDocumentTitle, useRequest } from '..'; +import { useSigninPageExtension } from './SigninPageExtension'; +import { useForm } from '@formily/react'; + +const SigninPageContext = createContext<{ + [authType: string]: { + component: FunctionComponent; + tabTitle?: string; + }; +}>({}); + +export const SigninPageProvider: React.FC<{ + authType: string; + component: FunctionComponent<{ authenticator: Authenticator }>; + tabTitle?: string; +}> = (props) => { + const components = useContext(SigninPageContext); + components[props.authType] = { + component: props.component, + tabTitle: props.tabTitle, + }; + return {props.children}; +}; + +export type Authenticator = { + name: string; + authType: string; + title?: string; + options?: { + [key: string]: any; + }; + sort?: number; +}; + +export const AuthenticatorsContext = createContext([]); + +export function useRedirect(next = '/admin') { + const location = useLocation(); + const history = useHistory(); + const redirect = location?.['query']?.redirect; + return useCallback(() => { + history.push(redirect || '/admin'); + }, [redirect, history]); +} + +export const useSignIn = (authenticator) => { + const form = useForm(); + const api = useAPIClient(); + const redirect = useRedirect(); + return { + async run() { + await form.submit(); + await api.auth.signIn(form.values, authenticator); + redirect(); + }, + }; +}; + +export const SigninPage = () => { + const { t } = useTranslation(); + useCurrentDocumentTitle('Signin'); + const signInPages = useContext(SigninPageContext); + const api = useAPIClient(); + const [authenticators, setAuthenticators] = useState([]); + const [tabs, setTabs] = useState< + (Authenticator & { + component: FunctionComponentElement<{ authenticator: Authenticator }>; + tabTitle: string; + })[] + >([]); + const signinExtension = useSigninPageExtension(authenticators); + + const handleAuthenticators = (authenticators: Authenticator[]) => { + const tabs = authenticators + .map((authenticator) => { + const page = signInPages[authenticator.authType]; + if (!page) { + return; + } + return { + component: createElement<{ + authenticator: Authenticator; + }>(page.component, { authenticator }), + tabTitle: authenticator.title || page.tabTitle || authenticator.name, + ...authenticator, + }; + }) + .filter((i) => i); + + setAuthenticators(authenticators); + setTabs(tabs); + }; + + useRequest( + () => + api + .resource('authenticators') + .publicList() + .then((res) => { + return res?.data?.data || []; + }), + { + onSuccess: (data) => { + handleAuthenticators(data); + }, + }, + ); + + if (!authenticators.length) { + return
{t('Oops! No authentication methods available.')}
; + } + + return ( + + + {tabs.length > 1 ? ( + + {tabs.map((tab) => ( + + {tab.component} + + ))} + + ) : tabs.length ? ( +
{tabs[0].component}
+ ) : ( + <> + )} + + {signinExtension} + +
+
+ ); +}; diff --git a/packages/core/client/src/auth/SigninPageExtension.tsx b/packages/core/client/src/auth/SigninPageExtension.tsx new file mode 100644 index 000000000..e5b453bc2 --- /dev/null +++ b/packages/core/client/src/auth/SigninPageExtension.tsx @@ -0,0 +1,40 @@ +import React, { createContext, FunctionComponent, useContext } from 'react'; +import { Authenticator, AuthenticatorsContext } from './SigninPage'; + +export interface SigninPageExtensionContextValue { + components: { + [authType: string]: FunctionComponent<{ + authenticator: Authenticator; + [key: string]: any; + }>; + }; +} + +export const SigninPageExtensionContext = createContext({ components: {} }); + +export const useSigninPageExtension = (authenticators = []) => { + const { components } = useContext(SigninPageExtensionContext); + const types = Object.keys(components); + return authenticators + .filter((authenticator) => types.includes(authenticator.authType)) + .map((authenticator, index) => + React.createElement(components[authenticator.authType], { key: index, authenticator }), + ); +}; + +export const SigninPageExtensionProvider: React.FC<{ + authType: string; + component: FunctionComponent<{ + authenticator: Authenticator; + [key: string]: any; + }>; +}> = (props) => { + const { components } = useContext(SigninPageExtensionContext); + if (!components[props.authType]) { + components[props.authType] = props.component; + } + + return ( + {props.children} + ); +}; diff --git a/packages/core/client/src/auth/SignupPage.tsx b/packages/core/client/src/auth/SignupPage.tsx new file mode 100644 index 000000000..a62d6bf61 --- /dev/null +++ b/packages/core/client/src/auth/SignupPage.tsx @@ -0,0 +1,75 @@ +import { message } from 'antd'; +import React, { useContext, createContext, FunctionComponent, createElement } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link, useHistory, useLocation, useParams } from 'react-router-dom'; +import { useAPIClient, useCurrentDocumentTitle } from '..'; +import { useForm } from '@formily/react'; + +export const SignupPageContext = createContext<{ + [authType: string]: { + component: FunctionComponent<{ + name: string; + }>; + }; +}>({}); + +export const SignupPageProvider: React.FC<{ + authType: string; + component: FunctionComponent<{ + name: string; + }>; +}> = (props) => { + const components = useContext(SignupPageContext); + components[props.authType] = { + component: props.component, + }; + return {props.children}; +}; + +export interface UseSignupProps { + authenticator?: string; + message?: { + success?: string; + }; +} + +export const useSignup = (props?: UseSignupProps) => { + const history = useHistory(); + const form = useForm(); + const api = useAPIClient(); + const { t } = useTranslation(); + return { + async run() { + await form.submit(); + await api.auth.signUp(form.values, props?.authenticator); + message.success(props?.message?.success || t('Sign up successfully, and automatically jump to the sign in page')); + setTimeout(() => { + history.push('/signin'); + }, 2000); + }, + }; +}; + +function useQuery() { + const { search } = useLocation(); + + return React.useMemo(() => new URLSearchParams(search), [search]); +} + +export const SignupPage = () => { + const { t } = useTranslation(); + useCurrentDocumentTitle('Signup'); + const query = useQuery(); + const authType = query.get('authType'); + const name = query.get('name'); + const signUpPages = useContext(SignupPageContext); + if (!signUpPages[authType]) { + return ( +
+
{t('Oops! The authentication type does not allow sign-up.')}
+ {t('Return')} +
+ ); + } + return createElement(signUpPages[authType].component, { name }); +}; diff --git a/packages/core/client/src/auth/index.ts b/packages/core/client/src/auth/index.ts new file mode 100644 index 000000000..145c29575 --- /dev/null +++ b/packages/core/client/src/auth/index.ts @@ -0,0 +1,4 @@ +export * from './SigninPage'; +export * from './SignupPage'; +export * from './OptionsComponent'; +export * from './SigninPageExtension'; diff --git a/packages/core/client/src/collection-manager/action-hooks.ts b/packages/core/client/src/collection-manager/action-hooks.ts index d212202ad..583116d58 100644 --- a/packages/core/client/src/collection-manager/action-hooks.ts +++ b/packages/core/client/src/collection-manager/action-hooks.ts @@ -362,11 +362,16 @@ export const useUpdateAction = () => { await form.submit(); field.data = field.data || {}; field.data.loading = true; - await resource.update({ filterByTk, values: form.values }); - ctx.setVisible(false); - await form.reset(); - field.data.loading = false; - refresh(); + try { + await resource.update({ filterByTk, values: form.values }); + ctx.setVisible(false); + await form.reset(); + refresh(); + } catch (e) { + console.log(e); + } finally { + field.data.loading = false; + } }, }; }; diff --git a/packages/core/client/src/index.ts b/packages/core/client/src/index.ts index 0f19eac59..d85408281 100644 --- a/packages/core/client/src/index.ts +++ b/packages/core/client/src/index.ts @@ -6,6 +6,7 @@ export * from './api-client'; export * from './appInfo'; export * from './application'; export * from './async-data-provider'; +export * from './auth'; export * from './block-provider'; export * from './board'; export * from './china-region'; diff --git a/packages/core/client/src/locale/zh_CN.ts b/packages/core/client/src/locale/zh_CN.ts index 417ff59c3..5840490cd 100644 --- a/packages/core/client/src/locale/zh_CN.ts +++ b/packages/core/client/src/locale/zh_CN.ts @@ -757,6 +757,7 @@ export default { 'Form data templates': '表单数据模板', 'Data template': '数据模板', + 'No configuration available.': '无可配置项。', 'Reload application': '重载应用', 'The application is reloading, please do not close the page.': '应用正在重新加载,请勿关闭页面。', 'Application reloading': '应用重新加载中', diff --git a/packages/core/client/src/pm/Card.tsx b/packages/core/client/src/pm/Card.tsx index 386465abe..86f02ea4a 100644 --- a/packages/core/client/src/pm/Card.tsx +++ b/packages/core/client/src/pm/Card.tsx @@ -293,7 +293,7 @@ export const PluginCard = (props: { data: IPluginData }) => { onChange={async (checked, e) => { e.stopPropagation(); Modal.warn({ - title: checked ? t('Plugin staring') : t('Plugin stopping'), + title: checked ? t('Plugin starting') : t('Plugin stopping'), content: t('The application is reloading, please do not close the page.'), okButtonProps: { style: { diff --git a/packages/core/client/src/system-settings/SystemSettingsShortcut.tsx b/packages/core/client/src/system-settings/SystemSettingsShortcut.tsx index a9cb2d533..0efbde53e 100644 --- a/packages/core/client/src/system-settings/SystemSettingsShortcut.tsx +++ b/packages/core/client/src/system-settings/SystemSettingsShortcut.tsx @@ -121,20 +121,20 @@ const schema: ISchema = { }); }, }, - allowSignUp: { - type: 'boolean', - default: true, - 'x-content': '{{t("Allow sign up")}}', - 'x-component': 'Checkbox', - 'x-decorator': 'FormItem', - }, - smsAuthEnabled: { - type: 'boolean', - default: false, - 'x-content': '{{t("Enable SMS authentication")}}', - 'x-component': 'Checkbox', - 'x-decorator': 'FormItem', - }, + // allowSignUp: { + // type: 'boolean', + // default: true, + // 'x-content': '{{t("Allow sign up")}}', + // 'x-component': 'Checkbox', + // 'x-decorator': 'FormItem', + // }, + // smsAuthEnabled: { + // type: 'boolean', + // default: false, + // 'x-content': '{{t("Enable SMS authentication")}}', + // 'x-component': 'Checkbox', + // 'x-decorator': 'FormItem', + // }, footer1: { type: 'void', 'x-component': 'Action.Drawer.Footer', @@ -227,20 +227,20 @@ const schema2: ISchema = { }); }, }, - allowSignUp: { - type: 'boolean', - default: true, - 'x-content': '{{t("Allow sign up")}}', - 'x-component': 'Checkbox', - 'x-decorator': 'FormItem', - }, - smsAuthEnabled: { - type: 'boolean', - default: false, - 'x-content': '{{t("Enable SMS authentication")}}', - 'x-component': 'Checkbox', - 'x-decorator': 'FormItem', - }, + // allowSignUp: { + // type: 'boolean', + // default: true, + // 'x-content': '{{t("Allow sign up")}}', + // 'x-component': 'Checkbox', + // 'x-decorator': 'FormItem', + // }, + // smsAuthEnabled: { + // type: 'boolean', + // default: false, + // 'x-content': '{{t("Enable SMS authentication")}}', + // 'x-component': 'Checkbox', + // 'x-decorator': 'FormItem', + // }, footer1: { type: 'void', 'x-component': 'ActionBar', diff --git a/packages/core/client/src/user/CurrentUser.tsx b/packages/core/client/src/user/CurrentUser.tsx index 57769de99..2c2821b2b 100644 --- a/packages/core/client/src/user/CurrentUser.tsx +++ b/packages/core/client/src/user/CurrentUser.tsx @@ -110,8 +110,7 @@ export const CurrentUser = () => { { - await api.resource('users').signout(); - api.auth.setToken(null); + await api.auth.signOut(); history.push('/signin'); }} > diff --git a/packages/core/client/src/user/CurrentUserProvider.tsx b/packages/core/client/src/user/CurrentUserProvider.tsx index 28c23e6af..d7deffc0e 100644 --- a/packages/core/client/src/user/CurrentUserProvider.tsx +++ b/packages/core/client/src/user/CurrentUserProvider.tsx @@ -12,7 +12,7 @@ export const useCurrentUserContext = () => { export const CurrentUserProvider = (props) => { const location = useLocation(); const result = useRequest({ - url: 'users:check', + url: 'auth:check', }); if (result.loading) { return ; diff --git a/packages/core/client/src/user/index.ts b/packages/core/client/src/user/index.ts index 25ff5c54c..8a509ec66 100644 --- a/packages/core/client/src/user/index.ts +++ b/packages/core/client/src/user/index.ts @@ -1,5 +1,5 @@ export * from './CurrentUser'; export * from './CurrentUserProvider'; -export * from './SigninPage'; -export * from './SignupPage'; -export * from './SigninPageExtension'; +// export * from './SigninPage'; +// export * from './SignupPage'; +// export * from './SigninPageExtension'; diff --git a/packages/core/sdk/src/APIClient.ts b/packages/core/sdk/src/APIClient.ts index d94f50c0f..1364d03ec 100644 --- a/packages/core/sdk/src/APIClient.ts +++ b/packages/core/sdk/src/APIClient.ts @@ -25,22 +25,23 @@ export interface IResource { export class Auth { protected api: APIClient; - protected NOCOBASE_LOCALE_KEY = 'NOCOBASE_LOCALE'; - - protected NOCOBASE_ROLE_KEY = 'NOCOBASE_ROLE'; + protected KEYS = { + locale: 'NOCOBASE_LOCALE', + role: 'NOCOBASE_ROLE', + token: 'NOCOBASE_TOKEN', + authenticator: 'NOCOBASE_AUTH', + }; protected options = { - token: null, locale: null, role: null, + authenticator: null, + token: null, }; constructor(api: APIClient) { this.api = api; this.initKeys(); - this.locale = this.getLocale(); - this.role = this.getRole(); - this.token = this.getToken(); this.api.axios.interceptors.request.use(this.middleware.bind(this)); } @@ -53,34 +54,89 @@ export class Auth { return; } const appName = match[1]; - this.NOCOBASE_LOCALE_KEY = `${appName.toUpperCase()}_NOCOBASE_LOCALE`; - this.NOCOBASE_ROLE_KEY = `${appName.toUpperCase()}_NOCOBASE_ROLE`; + this.KEYS['role'] = `${appName.toUpperCase()}_` + this.KEYS['role']; + this.KEYS['locale'] = `${appName.toUpperCase()}_` + this.KEYS['locale']; } get locale() { return this.getLocale(); } + set locale(value: string) { + this.setLocale(value); + } + get role() { return this.getRole(); } + set role(value: string) { + this.setRole(value); + } + get token() { return this.getToken(); } - set locale(value) { - this.setLocale(value); - } - - set role(value) { - this.setRole(value); - } - - set token(value) { + set token(value: string) { this.setToken(value); } + get authenticator() { + return this.getAuthenticator(); + } + + set authenticator(value: string) { + this.setAuthenticator(value); + } + + getOption(key: string) { + if (!this.KEYS[key]) { + return; + } + return this.api.storage.getItem(this.KEYS[key]); + } + + setOption(key: string, value?: string) { + if (!this.KEYS[key]) { + return; + } + this.options[key] = value; + return this.api.storage.setItem(this.KEYS[key], value || ''); + } + + getLocale() { + return this.getOption('locale'); + } + + setLocale(locale: string) { + this.setOption('locale', locale); + } + + getRole() { + return this.getOption('role'); + } + + setRole(role: string) { + this.setOption('role', role); + } + + getToken() { + return this.getOption('token'); + } + + setToken(token: string) { + this.setOption('token', token); + } + + getAuthenticator() { + return this.getOption('authenticator'); + } + + setAuthenticator(authenticator: string) { + this.setOption('authenticator', authenticator); + } + middleware(config: AxiosRequestConfig) { if (this.locale) { config.headers['X-Locale'] = this.locale; @@ -88,63 +144,50 @@ export class Auth { if (this.role) { config.headers['X-Role'] = this.role; } + if (this.authenticator && !config.headers['X-Authenticator']) { + config.headers['X-Authenticator'] = this.authenticator; + } if (this.token) { config.headers['Authorization'] = `Bearer ${this.token}`; } return config; } - getLocale() { - return this.api.storage.getItem(this.NOCOBASE_LOCALE_KEY); - } - - setLocale(locale: string) { - this.options.locale = locale; - this.api.storage.setItem(this.NOCOBASE_LOCALE_KEY, locale || ''); - } - - getToken() { - return this.api.storage.getItem('NOCOBASE_TOKEN'); - } - - setToken(token: string) { - this.options.token = token; - this.api.storage.setItem('NOCOBASE_TOKEN', token || ''); - if (!token) { - this.setRole(null); - // this.setLocale(null); - } - } - - getRole() { - return this.api.storage.getItem(this.NOCOBASE_ROLE_KEY); - } - - setRole(role: string) { - this.options.role = role; - this.api.storage.setItem(this.NOCOBASE_ROLE_KEY, role || ''); - } - - async signIn(values, authenticator = 'password'): Promise> { + async signIn(values: any, authenticator?: string): Promise> { const response = await this.api.request({ method: 'post', - url: 'users:signin', + url: 'auth:signIn', data: values, - params: { - authenticator, + headers: { + 'X-Authenticator': authenticator, }, }); const data = response?.data?.data; this.setToken(data?.token); + this.setAuthenticator(authenticator); return response; } - async signOut() { - await this.api.request({ + async signUp(values: any, authenticator?: string): Promise> { + return await this.api.request({ method: 'post', - url: 'users:signout', + url: 'auth:signUp', + data: values, + headers: { + 'X-Authenticator': authenticator, + }, + }); + } + + async signOut() { + const response = await this.api.request({ + method: 'post', + url: 'auth:signOut', }); this.setToken(null); + this.setRole(null); + this.setAuthenticator(null); + return response; } } diff --git a/packages/core/sdk/src/__tests__/api-client.test.ts b/packages/core/sdk/src/__tests__/api-client.test.ts index d15e346ca..155349bbb 100644 --- a/packages/core/sdk/src/__tests__/api-client.test.ts +++ b/packages/core/sdk/src/__tests__/api-client.test.ts @@ -24,13 +24,16 @@ describe('api-client', () => { baseURL: 'https://localhost:8000/api', }); const mock = new MockAdapter(api.axios); - mock.onPost('users:signin').reply(200, { + mock.onPost('auth:signIn').reply(200, { data: { id: 1, name: 'John Smith', token: '123' }, }); - const response = await api.auth.signIn({}); + const response = await api.auth.signIn({}, 'basic'); expect(response.status).toBe(200); - expect(api.auth.token).toBe('123'); - expect(localStorage.getItem('NOCOBASE_TOKEN')).toBe('123'); + expect(api.auth.getToken()).toBe('123'); + const token = localStorage.getItem('NOCOBASE_TOKEN'); + expect(token).toBe('123'); + const auth = localStorage.getItem('NOCOBASE_AUTH'); + expect(auth).toBe('basic'); }); test('resource action', async () => { @@ -72,14 +75,15 @@ describe('api-client', () => { }); const mock = new MockAdapter(api.axios); - mock.onPost('users:signin').reply(200, { + mock.onPost('auth:signIn').reply(200, { data: { id: 1, name: 'John Smith', token: '123' }, }); const response = await api.auth.signIn({}); expect(response.status).toBe(200); - expect(api.auth.token).toBe('123'); - expect(items.get('NOCOBASE_TOKEN')).toBe('123'); + expect(api.auth.getToken()).toBe('123'); + const token = items.get('NOCOBASE_TOKEN'); + expect(token).toBe('123'); }); test('custom auth', async () => { @@ -87,10 +91,11 @@ describe('api-client', () => { async signIn(values: any): Promise> { const response = await this.api.request({ method: 'post', - url: 'users:test', + url: 'auth:test', data: values, }); const data = response?.data?.data; + this.setAuthenticator('test'); this.setToken(data?.token); return response; } @@ -104,13 +109,16 @@ describe('api-client', () => { expect(api.auth).toBeInstanceOf(TestAuth); const mock = new MockAdapter(api.axios); - mock.onPost('users:test').reply(200, { + mock.onPost('auth:test').reply(200, { data: { id: 1, name: 'John Smith', token: '123' }, }); const response = await api.auth.signIn({}); expect(response.status).toBe(200); - expect(api.auth.token).toBe('123'); - expect(localStorage.getItem('NOCOBASE_TOKEN')).toBe('123'); + expect(api.auth.getToken()).toBe('123'); + const token = localStorage.getItem('NOCOBASE_TOKEN'); + expect(token).toBe('123'); + const auth = localStorage.getItem('NOCOBASE_AUTH'); + expect(auth).toBe('test'); }); }); diff --git a/packages/core/server/package.json b/packages/core/server/package.json index 843e42075..bbdb67f59 100644 --- a/packages/core/server/package.json +++ b/packages/core/server/package.json @@ -10,6 +10,7 @@ "@koa/router": "^9.4.0", "@nocobase/acl": "0.9.4-alpha.2", "@nocobase/actions": "0.9.4-alpha.2", + "@nocobase/auth": "0.9.4-alpha.2", "@nocobase/database": "0.9.4-alpha.2", "@nocobase/logger": "0.9.4-alpha.2", "@nocobase/resourcer": "0.9.4-alpha.2", @@ -30,4 +31,4 @@ "@types/semver": "^7.3.9" }, "gitHead": "ce588eefb0bfc50f7d5bbee575e0b5e843bf6644" -} +} \ No newline at end of file diff --git a/packages/core/server/src/application.ts b/packages/core/server/src/application.ts index 235549765..370e4d2bc 100644 --- a/packages/core/server/src/application.ts +++ b/packages/core/server/src/application.ts @@ -1,5 +1,6 @@ import { ACL } from '@nocobase/acl'; import { registerActions } from '@nocobase/actions'; +import { actions as authActions, AuthManager } from '@nocobase/auth'; import { Cache, createCache, ICacheConfig } from '@nocobase/cache'; import Database, { Collection, CollectionOptions, IDatabaseOptions } from '@nocobase/database'; import { AppLoggerOptions, createAppLogger, Logger } from '@nocobase/logger'; @@ -164,6 +165,8 @@ export class Application exten protected _appManager: AppManager; + protected _authManager: AuthManager; + protected _version: ApplicationVersion; protected plugins = new Map(); @@ -215,6 +218,10 @@ export class Application exten return this._appManager; } + get authManager() { + return this._authManager; + } + get logger() { return this._logger; } @@ -277,8 +284,18 @@ export class Application exten this._appManager = new AppManager(this); } + this._authManager = new AuthManager({ + authKey: 'X-Authenticator', + default: 'basic', + }); + this.resource({ + name: 'auth', + actions: authActions, + }); + this._resourcer.use(this._authManager.middleware(), { tag: 'auth' }); + if (this.options.acl !== false) { - this._resourcer.use(this._acl.middleware(), { tag: 'acl', after: ['parseToken'] }); + this._resourcer.use(this._acl.middleware(), { tag: 'acl', after: ['auth'] }); } registerMiddlewares(this, options); diff --git a/packages/core/test/src/mockServer.ts b/packages/core/test/src/mockServer.ts index 2249ea28f..61e96676a 100644 --- a/packages/core/test/src/mockServer.ts +++ b/packages/core/test/src/mockServer.ts @@ -1,5 +1,6 @@ import { Database, mockDatabase } from '@nocobase/database'; import Application, { ApplicationOptions, PluginManager } from '@nocobase/server'; +import jwt from 'jsonwebtoken'; import qs from 'qs'; import supertest, { SuperAgentTest } from 'supertest'; @@ -80,11 +81,30 @@ export class MockServer extends Application { await this.db.clean({ drop: true }); } - agent(): SuperAgentTest & { resource: (name: string, resourceOf?: any) => Resource } { + agent(): SuperAgentTest & { + login: (user: any) => SuperAgentTest; + loginUsingId: (userId: number) => SuperAgentTest; + resource: (name: string, resourceOf?: any) => Resource; + } { const agent = supertest.agent(this.appManager.callback()); const prefix = this.resourcer.options.prefix; const proxy = new Proxy(agent, { get(target, method: string, receiver) { + if (['login', 'loginUsingId'].includes(method)) { + return (userOrId: any) => { + return proxy + .auth( + jwt.sign( + { + userId: typeof userOrId === 'number' ? userOrId : userOrId?.id, + }, + process.env.APP_KEY, + ), + { type: 'bearer' }, + ) + .set('X-Authenticator', 'basic'); + }; + } if (method === 'resource') { return (name: string, resourceOf?: any) => { const keys = name.split('.'); diff --git a/packages/plugins/acl/src/__tests__/acl.test.ts b/packages/plugins/acl/src/__tests__/acl.test.ts index 7f40c3c00..e898b8a10 100644 --- a/packages/plugins/acl/src/__tests__/acl.test.ts +++ b/packages/plugins/acl/src/__tests__/acl.test.ts @@ -32,15 +32,7 @@ describe('acl', () => { }, }); - userPlugin = app.getPlugin('users') as UsersPlugin; - - adminAgent = app.agent().auth( - userPlugin.jwtService.sign({ - userId: admin.get('id'), - }), - { type: 'bearer' }, - ); - + adminAgent = app.agent().login(admin); uiSchemaRepository = db.getRepository('uiSchemas'); }); @@ -248,12 +240,7 @@ describe('acl', () => { }); const userPlugin = app.getPlugin('users') as UsersPlugin; - const adminAgent = app.agent().auth( - userPlugin.jwtService.sign({ - userId: rootUser.get('id'), - }), - { type: 'bearer' }, - ); + const adminAgent = app.agent().login(rootUser); expect(await db.getCollection('roles').repository.count()).toBe(3); @@ -759,13 +746,7 @@ describe('acl', () => { }, }); - const userPlugin = app.getPlugin('users') as UsersPlugin; - const userAgent = app.agent().auth( - userPlugin.jwtService.sign({ - userId: user.get('id'), - }), - { type: 'bearer' }, - ); + const userAgent = app.agent().login(user); const schema = { 'x-uid': 'test', @@ -823,21 +804,11 @@ describe('acl', () => { }); it('should destroy new role when user are root user', async () => { - const roles = await db.getRepository('roles').find(); - - const users = await db.getRepository('users').find(); const rootUser = await db.getRepository('users').findOne({ filterByTk: 1, }); - const userPlugin = app.getPlugin('users') as UsersPlugin; - - const rootAgent = app.agent().auth( - userPlugin.jwtService.sign({ - userId: rootUser.get('id'), - }), - { type: 'bearer' }, - ); + const rootAgent = app.agent().login(rootUser); const response = await rootAgent // @ts-ignore diff --git a/packages/plugins/acl/src/__tests__/association-field.test.ts b/packages/plugins/acl/src/__tests__/association-field.test.ts index 95c579c81..0bb7ed942 100644 --- a/packages/plugins/acl/src/__tests__/association-field.test.ts +++ b/packages/plugins/acl/src/__tests__/association-field.test.ts @@ -89,14 +89,7 @@ describe('association test', () => { }, }); - const userPlugin = app.getPlugin('users') as UsersPlugin; - - const userAgent = app.agent().auth( - userPlugin.jwtService.sign({ - userId: user.get('id'), - }), - { type: 'bearer' }, - ); + const userAgent = app.agent().login(user); //@ts-ignore const response = await userAgent.resource('posts').list({}); @@ -153,19 +146,9 @@ describe('association field acl', () => { }); const userPlugin = app.getPlugin('users') as UsersPlugin; - userAgent = app.agent().auth( - userPlugin.jwtService.sign({ - userId: user.get('id'), - }), - { type: 'bearer' }, - ); + userAgent = app.agent().login(user); - adminAgent = app.agent().auth( - userPlugin.jwtService.sign({ - userId: admin.get('id'), - }), - { type: 'bearer' }, - ); + adminAgent = app.agent().login(admin); await db.getRepository('collections').create({ values: { diff --git a/packages/plugins/acl/src/__tests__/configuration.test.ts b/packages/plugins/acl/src/__tests__/configuration.test.ts index eea160357..c19c4f231 100644 --- a/packages/plugins/acl/src/__tests__/configuration.test.ts +++ b/packages/plugins/acl/src/__tests__/configuration.test.ts @@ -46,19 +46,9 @@ describe('configuration', () => { }); const userPlugin = app.getPlugin('users') as UsersPlugin; - adminAgent = app.agent().auth( - userPlugin.jwtService.sign({ - userId: admin.get('id'), - }), - { type: 'bearer' }, - ); + adminAgent = app.agent().login(admin); - userAgent = app.agent().auth( - userPlugin.jwtService.sign({ - userId: user.get('id'), - }), - { type: 'bearer' }, - ); + userAgent = app.agent().login(user); guestAgent = app.agent(); }); diff --git a/packages/plugins/acl/src/__tests__/list-action.test.ts b/packages/plugins/acl/src/__tests__/list-action.test.ts index 2f31fec4b..c71c958b2 100644 --- a/packages/plugins/acl/src/__tests__/list-action.test.ts +++ b/packages/plugins/acl/src/__tests__/list-action.test.ts @@ -1,6 +1,6 @@ import { Database } from '@nocobase/database'; -import { prepareApp } from './prepare'; import { MockServer } from '@nocobase/test'; +import { prepareApp } from './prepare'; describe('list action with acl', () => { let app: MockServer; @@ -302,15 +302,7 @@ describe('list association action with acl', () => { }); const userPlugin = app.getPlugin('users'); - const userAgent = app - .agent() - .set('X-With-ACL-Meta', true) - .auth( - userPlugin.jwtService.sign({ - userId: user.get('id'), - }), - { type: 'bearer' }, - ); + const userAgent = app.agent().login(user).set('X-With-ACL-Meta', true); await userAgent.resource('posts').create({ values: { @@ -349,16 +341,7 @@ describe('list association action with acl', () => { }); const userPlugin = app.getPlugin('users'); - const agent = app - .agent() - .set('X-With-ACL-Meta', true) - .auth( - userPlugin.jwtService.sign({ - userId: user.get('id'), - }), - { type: 'bearer' }, - ); - + const agent = app.agent().login(user).set('X-With-ACL-Meta', true); app.acl.allow('table_a', ['*']); app.acl.allow('collections', ['*']); diff --git a/packages/plugins/acl/src/__tests__/middleware.test.ts b/packages/plugins/acl/src/__tests__/middleware.test.ts index 8cf7a5de9..752c799c3 100644 --- a/packages/plugins/acl/src/__tests__/middleware.test.ts +++ b/packages/plugins/acl/src/__tests__/middleware.test.ts @@ -31,12 +31,7 @@ describe('middleware', () => { }); const userPlugin = app.getPlugin('users') as UsersPlugin; - adminAgent = app.agent().auth( - userPlugin.jwtService.sign({ - userId: admin.get('id'), - }), - { type: 'bearer' }, - ); + adminAgent = app.agent().login(admin); await db.getRepository('collections').create({ values: { diff --git a/packages/plugins/acl/src/__tests__/own.test.ts b/packages/plugins/acl/src/__tests__/own.test.ts index 8d7df0dcc..a26099bcd 100644 --- a/packages/plugins/acl/src/__tests__/own.test.ts +++ b/packages/plugins/acl/src/__tests__/own.test.ts @@ -2,7 +2,6 @@ import { ACL } from '@nocobase/acl'; import { Database } from '@nocobase/database'; import PluginUser from '@nocobase/plugin-users'; import { MockServer } from '@nocobase/test'; - import { prepareApp } from './prepare'; describe('own test', () => { @@ -69,9 +68,7 @@ describe('own test', () => { pluginUser = app.getPlugin('users'); - adminToken = pluginUser.jwtService.sign({ userId: admin.get('id') }); - - adminAgent = app.agent().auth(adminToken, { type: 'bearer' }); + adminAgent = app.agent().login(admin); user = await db.getRepository('users').create({ values: { @@ -80,9 +77,7 @@ describe('own test', () => { }, }); - userToken = pluginUser.jwtService.sign({ userId: user.get('id') }); - - userAgent = app.agent().auth(userToken, { type: 'bearer' }); + userAgent = app.agent().login(user); }); it('should list without createBy', async () => { diff --git a/packages/plugins/acl/src/__tests__/prepare.ts b/packages/plugins/acl/src/__tests__/prepare.ts index 270e86f4e..29d8a709b 100644 --- a/packages/plugins/acl/src/__tests__/prepare.ts +++ b/packages/plugins/acl/src/__tests__/prepare.ts @@ -5,7 +5,7 @@ export async function prepareApp(): Promise { const app = mockServer({ registerActions: true, acl: true, - plugins: ['error-handler', 'users', 'ui-schema-storage', 'collection-manager'], + plugins: ['error-handler', 'users', 'ui-schema-storage', 'collection-manager', 'auth'], }); app.plugin(PluginACL, { diff --git a/packages/plugins/acl/src/__tests__/role-check.test.ts b/packages/plugins/acl/src/__tests__/role-check.test.ts index 0355d7c55..472f69f2f 100644 --- a/packages/plugins/acl/src/__tests__/role-check.test.ts +++ b/packages/plugins/acl/src/__tests__/role-check.test.ts @@ -1,7 +1,6 @@ import { Database } from '@nocobase/database'; import UsersPlugin from '@nocobase/plugin-users'; import { MockServer } from '@nocobase/test'; - import { prepareApp } from './prepare'; describe('role check action', () => { @@ -37,12 +36,7 @@ describe('role check action', () => { }); const userPlugin = app.getPlugin('users') as UsersPlugin; - const agent = app.agent().auth( - userPlugin.jwtService.sign({ - userId: user.get('id'), - }), - { type: 'bearer' }, - ); + const agent = app.agent().login(user); // @ts-ignore const response = await agent.resource('roles').check(); diff --git a/packages/plugins/acl/src/__tests__/role-resource.test.ts b/packages/plugins/acl/src/__tests__/role-resource.test.ts index 669864e48..b4b466a45 100644 --- a/packages/plugins/acl/src/__tests__/role-resource.test.ts +++ b/packages/plugins/acl/src/__tests__/role-resource.test.ts @@ -33,12 +33,7 @@ describe('role resource api', () => { }); const userPlugin = app.getPlugin('users') as UsersPlugin; - adminAgent = app.agent().auth( - userPlugin.jwtService.sign({ - userId: admin.get('id'), - }), - { type: 'bearer' }, - ); + adminAgent = app.agent().login(admin); }); it('should grant resource by createRepository', async () => { diff --git a/packages/plugins/acl/src/__tests__/role-user.test.ts b/packages/plugins/acl/src/__tests__/role-user.test.ts index c48527554..b945e7bf6 100644 --- a/packages/plugins/acl/src/__tests__/role-user.test.ts +++ b/packages/plugins/acl/src/__tests__/role-user.test.ts @@ -2,6 +2,8 @@ import Database, { BelongsToManyRepository } from '@nocobase/database'; import PluginACL from '@nocobase/plugin-acl'; import UsersPlugin from '@nocobase/plugin-users'; import { MockServer, mockServer } from '@nocobase/test'; +import jwt from 'jsonwebtoken'; +import AuthPlugin from '@nocobase/plugin-auth'; describe('role', () => { let api: MockServer; @@ -14,6 +16,7 @@ describe('role', () => { await api.cleanDb(); api.plugin(UsersPlugin, { name: 'users' }); api.plugin(PluginACL, { name: 'acl' }); + api.plugin(AuthPlugin, { name: 'auth' }); await api.loadAndInstall(); db = api.db; @@ -102,7 +105,7 @@ describe('role', () => { await userRolesRepo.add('test1'); await userRolesRepo.add('test2'); - const userToken = usersPlugin.jwtService.sign({ userId: user.get('id') }); + const userToken = jwt.sign({ userId: user.get('id') }, 'test-key'); const response = await api .agent() .post('/users:setDefaultRole') @@ -111,6 +114,7 @@ describe('role', () => { }) .set({ Authorization: `Bearer ${userToken}`, + 'X-Authenticator': 'basic', }); expect(response.statusCode).toEqual(200); diff --git a/packages/plugins/acl/src/__tests__/role.test.ts b/packages/plugins/acl/src/__tests__/role.test.ts index c62df40e4..fee341fa3 100644 --- a/packages/plugins/acl/src/__tests__/role.test.ts +++ b/packages/plugins/acl/src/__tests__/role.test.ts @@ -1,7 +1,6 @@ import { ArrayFieldRepository, Database, Model } from '@nocobase/database'; import UsersPlugin from '@nocobase/plugin-users'; import { MockServer } from '@nocobase/test'; - import { prepareApp } from './prepare'; describe('role api', () => { @@ -37,12 +36,7 @@ describe('role api', () => { }); const userPlugin = app.getPlugin('users') as UsersPlugin; - adminAgent = app.agent().auth( - userPlugin.jwtService.sign({ - userId: admin.get('id'), - }), - { type: 'bearer' }, - ); + adminAgent = app.agent().login(admin); }); it('should list actions', async () => { diff --git a/packages/plugins/acl/src/__tests__/scope.test.ts b/packages/plugins/acl/src/__tests__/scope.test.ts index d0c88278c..9c56177ba 100644 --- a/packages/plugins/acl/src/__tests__/scope.test.ts +++ b/packages/plugins/acl/src/__tests__/scope.test.ts @@ -26,12 +26,7 @@ describe('scope api', () => { }); const userPlugin = app.getPlugin('users') as UsersPlugin; - adminAgent = app.agent().auth( - userPlugin.jwtService.sign({ - userId: admin.get('id'), - }), - { type: 'bearer' }, - ); + adminAgent = app.agent().login(admin); }); it('should create scope of resource', async () => { diff --git a/packages/plugins/acl/src/__tests__/snippets.test.ts b/packages/plugins/acl/src/__tests__/snippets.test.ts index e92f4af8e..ef8891625 100644 --- a/packages/plugins/acl/src/__tests__/snippets.test.ts +++ b/packages/plugins/acl/src/__tests__/snippets.test.ts @@ -27,13 +27,7 @@ describe('snippet', () => { }); const userPlugin: any = app.getPlugin('users'); - const userAgent: any = app.agent().auth( - userPlugin.jwtService.sign({ - userId: user.get('id'), - }), - { type: 'bearer' }, - ); - + const userAgent: any = app.agent().login(user); const createCollectionResponse = await userAgent.resource('collections').create({}); expect(createCollectionResponse.statusCode).toEqual(403); diff --git a/packages/plugins/acl/src/__tests__/users.test.ts b/packages/plugins/acl/src/__tests__/users.test.ts index 4ab735bb9..e80c2ed42 100644 --- a/packages/plugins/acl/src/__tests__/users.test.ts +++ b/packages/plugins/acl/src/__tests__/users.test.ts @@ -27,12 +27,7 @@ describe('actions', () => { }); agent = app.agent(); - adminAgent = app.agent().auth( - pluginUser.jwtService.sign({ - userId: adminUser.get('id'), - }), - { type: 'bearer' }, - ); + adminAgent = app.agent().login(adminUser); }); afterEach(async () => { @@ -70,7 +65,7 @@ describe('actions', () => { }, }); - let response = await agent.post('/users:signin').send({ + let response = await agent.post('/auth:signIn').send({ email: 'test2@nocobase.com', password: '123456', }); @@ -89,7 +84,7 @@ describe('actions', () => { filterByTk: 'test', }); - response = await agent.post('/users:signin').send({ + response = await agent.post('/auth:signIn').send({ email: 'test2@nocobase.com', password: '123456', }); diff --git a/packages/plugins/acl/src/__tests__/write-role-to-acl.test.ts b/packages/plugins/acl/src/__tests__/write-role-to-acl.test.ts index 2a27d9a34..9e9ad281e 100644 --- a/packages/plugins/acl/src/__tests__/write-role-to-acl.test.ts +++ b/packages/plugins/acl/src/__tests__/write-role-to-acl.test.ts @@ -1,7 +1,6 @@ import { MockServer } from '@nocobase/test'; import { Database } from '@nocobase/database'; import { prepareApp } from './prepare'; -import UsersPlugin from '@nocobase/plugin-users'; describe('write role to acl', () => { let app: MockServer; @@ -32,13 +31,7 @@ describe('write role to acl', () => { }, }); - const userPlugin = app.getPlugin('users') as UsersPlugin; - const agent = app.agent().auth( - userPlugin.jwtService.sign({ - userId: user.get('id'), - }), - { type: 'bearer' }, - ); + const agent = app.agent().login(user); // @ts-ignore const response = await agent.resource('roles').check(); diff --git a/packages/plugins/acl/src/server.ts b/packages/plugins/acl/src/server.ts index 53e3428c8..87b65be9b 100644 --- a/packages/plugins/acl/src/server.ts +++ b/packages/plugins/acl/src/server.ts @@ -434,7 +434,7 @@ export class PluginACL extends Plugin { }); }); - this.app.resourcer.use(setCurrentRole, { tag: 'setCurrentRole', before: 'acl', after: 'parseToken' }); + this.app.resourcer.use(setCurrentRole, { tag: 'setCurrentRole', before: 'acl', after: 'auth' }); this.app.acl.allow('users', 'setDefaultRole', 'loggedIn'); this.app.acl.allow('roles', 'check', 'loggedIn'); diff --git a/packages/plugins/auth/README.md b/packages/plugins/auth/README.md new file mode 100644 index 000000000..7fb8535a6 --- /dev/null +++ b/packages/plugins/auth/README.md @@ -0,0 +1,117 @@ +# Auth + +提供基础认证功能和扩展认证器管理功能。 + +## 使用方法 + +### 认证器管理 +页面:系统设置 - 认证 + + + + +#### 内置认证器 +- 名称:basic +- 认证类型:邮箱密码登录 + + + + + +#### 增加认证器 +Add new - 选择认证类型 + + + +#### 启用/禁用 +Actions - Edit - 勾选/取消Enabled + + + +#### 配置认证器 +Actions - Edit + +## 开发一个登录插件 +### 接口 +Nocobase内核提供了扩展登录方式的接入和管理。扩展登录插件的核心逻辑处理,需要继承内核的`Auth`类,并对相应的标准接口进行实现。 +参考`core/auth/auth.ts` + +```TypeScript +import { Auth } from '@nocobase/auth'; + +class CustomAuth extends Auth { + set user(user) {} + get user() {} + + async check() {} + async signIn() {} +} +``` + +多数情况下,扩展的用户登录方式也将沿用现有的jwt逻辑来生成用户访问API的凭证,插件也可以继承`BaseAuth`类以便复用部分逻辑代码,如`check`, `signIn`接口。 + +```TypeScript +import { BaseAuth } from '@nocobase/auth'; + +class CustomAuth extends BaseAuth { + constructor(config: AuthConfig) { + const userCollection = config.ctx.db.getCollection('users'); + super({ ...config, userCollection }); + } + + async validate() {} +} +``` + +### 用户数据 + +`@nocobase/plugin-auth`插件提供了`usersAuthenticators`表来建立users和authenticators,即用户和认证方式的关联。 +通常情况下,扩展登录方式用`users`和`usersAuthenticators`来存储相应的用户数据即可,特殊情况下才需要自己新增Collection. +`users`存储的是最基础的用户数据,邮箱、昵称和密码。 +`usersAuthenticators`存储扩展登录方式数据 +- uuid: 该种认证方式的用户唯一标识,如手机号、微信openid等 +- meta: JSON字段,其他需要保存的信息 +- userId: 用户id +- authenticator:认证器名字 + +对于用户操作,`Authenticator`模型也提供了几个封装的方法,可以在`CustomAuth`类中通过`this.authenticator[方法名]`使用: +- `findUser(uuid: string): UserModel` - 查询用户 +- `newUser(uuid: string, values?: any): UserModel` - 创建新用户 +- `findOrCreateUser(uuid: string, userValues?: any): UserModel` - 查找或创建新用户 + +### 注册 +扩展的登录方式需要向内核注册。 +```TypeScript +async load() { + this.app.authManager.registerTypes('custom-auth-type', { + auth: CustomAuth, + }); +} +``` + +### 客户端API +#### OptionsComponentProvider +可供用户配置的认证器配置项 +- authType 认证方式 +- component 配置组件 +```TypeScript + +``` + +`Options`组件使用的值是`authenticator`的`options`字段,如果有需要暴露在前端的配置,应该放在`options.public`字段中。`authenticators:publicList`接口会返回`options.public`字段的值。 + +#### SigninPageProvider +自定义登录页界面 +- authType 认证方式 +- tabTitle 登录页tab标题 +- component 登录页组件 + +#### SignupPageProvider +自定义注册页界面 +- authType 认证方式 +- component 注册页组件 + +#### SigninPageExtensionProvider +自定义登录页下方的扩展内容 +- authType 认证方式 +- component 扩展组件 diff --git a/packages/plugins/auth/client.d.ts b/packages/plugins/auth/client.d.ts new file mode 100755 index 000000000..bd53a2f77 --- /dev/null +++ b/packages/plugins/auth/client.d.ts @@ -0,0 +1,3 @@ +// @ts-nocheck +export * from './lib/client'; +export { default } from './lib/client'; diff --git a/packages/plugins/auth/client.js b/packages/plugins/auth/client.js new file mode 100755 index 000000000..c83e7e450 --- /dev/null +++ b/packages/plugins/auth/client.js @@ -0,0 +1,65 @@ +'use strict'; + +function _getRequireWildcardCache(nodeInterop) { + if (typeof WeakMap !== 'function') return null; + var cacheBabelInterop = new WeakMap(); + var cacheNodeInterop = new WeakMap(); + return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { + return nodeInterop ? cacheNodeInterop : cacheBabelInterop; + })(nodeInterop); +} + +function _interopRequireWildcard(obj, nodeInterop) { + if (!nodeInterop && obj && obj.__esModule) { + return obj; + } + if (obj === null || (typeof obj !== 'object' && typeof obj !== 'function')) { + return { default: obj }; + } + var cache = _getRequireWildcardCache(nodeInterop); + if (cache && cache.has(obj)) { + return cache.get(obj); + } + var newObj = {}; + var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; + for (var key in obj) { + if (key !== 'default' && Object.prototype.hasOwnProperty.call(obj, key)) { + var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; + if (desc && (desc.get || desc.set)) { + Object.defineProperty(newObj, key, desc); + } else { + newObj[key] = obj[key]; + } + } + } + newObj.default = obj; + if (cache) { + cache.set(obj, newObj); + } + return newObj; +} + +var _index = _interopRequireWildcard(require('./lib/client')); + +Object.defineProperty(exports, '__esModule', { + value: true, +}); +var _exportNames = {}; +Object.defineProperty(exports, 'default', { + enumerable: true, + get: function get() { + return _index.default; + }, +}); + +Object.keys(_index).forEach(function (key) { + if (key === 'default' || key === '__esModule') return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _index[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function get() { + return _index[key]; + }, + }); +}); diff --git a/packages/plugins/auth/package.json b/packages/plugins/auth/package.json new file mode 100644 index 000000000..d283b4254 --- /dev/null +++ b/packages/plugins/auth/package.json @@ -0,0 +1,16 @@ +{ + "name": "@nocobase/plugin-auth", + "version": "0.9.4-alpha.2", + "main": "lib/server/index.js", + "devDependencies": { + "@nocobase/server": "0.9.4-alpha.2", + "@nocobase/test": "0.9.4-alpha.2", + "@nocobase/client": "0.9.4-alpha.2", + "@nocobase/database": "0.9.4-alpha.2", + "@nocobase/actions": "0.9.4-alpha.2" + }, + "displayName": "Authentication", + "displayName.zh-CN": "用户认证", + "description": "Basic authentication and authenticator management.", + "description.zh-CN": "提供基础认证功能和扩展认证器管理功能。" +} \ No newline at end of file diff --git a/packages/plugins/auth/server.d.ts b/packages/plugins/auth/server.d.ts new file mode 100755 index 000000000..4d922a91b --- /dev/null +++ b/packages/plugins/auth/server.d.ts @@ -0,0 +1,3 @@ +// @ts-nocheck +export * from './lib/server'; +export { default } from './lib/server'; diff --git a/packages/plugins/auth/server.js b/packages/plugins/auth/server.js new file mode 100755 index 000000000..4f16903a6 --- /dev/null +++ b/packages/plugins/auth/server.js @@ -0,0 +1,65 @@ +'use strict'; + +function _getRequireWildcardCache(nodeInterop) { + if (typeof WeakMap !== 'function') return null; + var cacheBabelInterop = new WeakMap(); + var cacheNodeInterop = new WeakMap(); + return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { + return nodeInterop ? cacheNodeInterop : cacheBabelInterop; + })(nodeInterop); +} + +function _interopRequireWildcard(obj, nodeInterop) { + if (!nodeInterop && obj && obj.__esModule) { + return obj; + } + if (obj === null || (typeof obj !== 'object' && typeof obj !== 'function')) { + return { default: obj }; + } + var cache = _getRequireWildcardCache(nodeInterop); + if (cache && cache.has(obj)) { + return cache.get(obj); + } + var newObj = {}; + var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; + for (var key in obj) { + if (key !== 'default' && Object.prototype.hasOwnProperty.call(obj, key)) { + var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; + if (desc && (desc.get || desc.set)) { + Object.defineProperty(newObj, key, desc); + } else { + newObj[key] = obj[key]; + } + } + } + newObj.default = obj; + if (cache) { + cache.set(obj, newObj); + } + return newObj; +} + +var _index = _interopRequireWildcard(require('./lib/server')); + +Object.defineProperty(exports, '__esModule', { + value: true, +}); +var _exportNames = {}; +Object.defineProperty(exports, 'default', { + enumerable: true, + get: function get() { + return _index.default; + }, +}); + +Object.keys(_index).forEach(function (key) { + if (key === 'default' || key === '__esModule') return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _index[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function get() { + return _index[key]; + }, + }); +}); diff --git a/packages/plugins/auth/src/client/basic/Options.tsx b/packages/plugins/auth/src/client/basic/Options.tsx new file mode 100644 index 000000000..f8d4aa210 --- /dev/null +++ b/packages/plugins/auth/src/client/basic/Options.tsx @@ -0,0 +1,31 @@ +import { SchemaComponent } from '@nocobase/client'; +import React from 'react'; +import { useAuthTranslation } from '../locale'; + +export const Options = () => { + const { t } = useAuthTranslation(); + return ( + + ); +}; diff --git a/packages/plugins/auth/src/client/basic/SigninPage.tsx b/packages/plugins/auth/src/client/basic/SigninPage.tsx new file mode 100644 index 000000000..a0d182dd4 --- /dev/null +++ b/packages/plugins/auth/src/client/basic/SigninPage.tsx @@ -0,0 +1,65 @@ +import { Authenticator, SchemaComponent, SignupPageContext, useSignIn } from '@nocobase/client'; +import { ISchema } from '@formily/react'; +import React, { useContext } from 'react'; + +const passwordForm: ISchema = { + type: 'object', + name: 'passwordForm', + 'x-component': 'FormV2', + properties: { + email: { + type: 'string', + required: true, + 'x-component': 'Input', + 'x-validator': 'email', + 'x-decorator': 'FormItem', + 'x-component-props': { placeholder: '{{t("Email")}}', style: {} }, + }, + password: { + type: 'string', + 'x-component': 'Password', + required: true, + 'x-decorator': 'FormItem', + 'x-component-props': { placeholder: '{{t("Password")}}', style: {} }, + }, + actions: { + type: 'void', + 'x-component': 'div', + properties: { + submit: { + title: '{{t("Sign in")}}', + type: 'void', + 'x-component': 'Action', + 'x-component-props': { + htmlType: 'submit', + block: true, + type: 'primary', + useAction: `{{ useBasicSignIn }}`, + style: { width: '100%' }, + }, + }, + }, + }, + signup: { + type: 'void', + 'x-component': 'Link', + 'x-component-props': { + to: '{{ signupLink }}', + }, + 'x-content': '{{t("Create an account")}}', + 'x-visible': '{{ allowSignUp }}', + }, + }, +}; +export default (props: { authenticator: Authenticator }) => { + const authenticator = props.authenticator; + const { authType, name, options } = authenticator; + const signupPages = useContext(SignupPageContext); + const allowSignUp = !!signupPages[authType] && options?.allowSignup; + const signupLink = `/signup?authType=${authType}&name=${name}`; + + const useBasicSignIn = () => { + return useSignIn(name); + }; + return ; +}; diff --git a/packages/plugins/auth/src/client/basic/SignupPage.tsx b/packages/plugins/auth/src/client/basic/SignupPage.tsx new file mode 100644 index 000000000..b8f9c432f --- /dev/null +++ b/packages/plugins/auth/src/client/basic/SignupPage.tsx @@ -0,0 +1,91 @@ +import { SchemaComponent, useSignup } from '@nocobase/client'; +import { ISchema } from '@formily/react'; +import React from 'react'; +import { uid } from '@formily/shared'; + +const signupPageSchema: ISchema = { + type: 'object', + name: uid(), + 'x-component': 'FormV2', + properties: { + email: { + type: 'string', + required: true, + 'x-component': 'Input', + 'x-validator': 'email', + 'x-decorator': 'FormItem', + 'x-component-props': { placeholder: '{{t("Email")}}', style: {} }, + }, + password: { + type: 'string', + required: true, + 'x-component': 'Password', + 'x-decorator': 'FormItem', + 'x-component-props': { placeholder: '{{t("Password")}}', checkStrength: true, style: {} }, + 'x-reactions': [ + { + dependencies: ['.confirm_password'], + fulfill: { + state: { + selfErrors: '{{$deps[0] && $self.value && $self.value !== $deps[0] ? t("Password mismatch") : ""}}', + }, + }, + }, + ], + }, + confirm_password: { + type: 'string', + required: true, + 'x-component': 'Password', + 'x-decorator': 'FormItem', + 'x-component-props': { placeholder: '{{t("Confirm password")}}', style: {} }, + 'x-reactions': [ + { + dependencies: ['.password'], + fulfill: { + state: { + selfErrors: '{{$deps[0] && $self.value && $self.value !== $deps[0] ? t("Password mismatch") : ""}}', + }, + }, + }, + ], + }, + actions: { + type: 'void', + 'x-component': 'div', + properties: { + submit: { + title: '{{t("Sign up")}}', + type: 'void', + 'x-component': 'Action', + 'x-component-props': { + block: true, + type: 'primary', + htmlType: 'submit', + useAction: '{{ useBasicSignup }}', + style: { width: '100%' }, + }, + }, + }, + }, + link: { + type: 'void', + 'x-component': 'div', + properties: { + link: { + type: 'void', + 'x-component': 'Link', + 'x-component-props': { to: '/signin' }, + 'x-content': '{{t("Log in with an existing account")}}', + }, + }, + }, + }, +}; + +export default (props: { name: string }) => { + const useBasicSignup = () => { + return useSignup({ authenticator: props.name }); + }; + return ; +}; diff --git a/packages/plugins/auth/src/client/index.tsx b/packages/plugins/auth/src/client/index.tsx new file mode 100644 index 000000000..68a3627e6 --- /dev/null +++ b/packages/plugins/auth/src/client/index.tsx @@ -0,0 +1,41 @@ +import { + OptionsComponentProvider, + SettingsCenterProvider, + SigninPageProvider, + SignupPageProvider, +} from '@nocobase/client'; +import React from 'react'; +import { Authenticator } from './settings/Authenticator'; +import SigninPage from './basic/SigninPage'; +import { presetAuthType } from '../preset'; +import SignupPage from './basic/SignupPage'; +import { useAuthTranslation } from './locale'; +import { Options } from './basic/Options'; + +export default (props) => { + const { t } = useAuthTranslation(); + return ( + , + }, + }, + }, + }} + > + + + + {props.children} + + + + + ); +}; diff --git a/packages/plugins/auth/src/client/locale/index.ts b/packages/plugins/auth/src/client/locale/index.ts new file mode 100644 index 000000000..99bde0f3b --- /dev/null +++ b/packages/plugins/auth/src/client/locale/index.ts @@ -0,0 +1,7 @@ +import { useTranslation } from 'react-i18next'; + +export const NAMESPACE = 'auth'; + +export function useAuthTranslation() { + return useTranslation(NAMESPACE); +} diff --git a/packages/plugins/auth/src/client/locale/zh-CN.ts b/packages/plugins/auth/src/client/locale/zh-CN.ts new file mode 100644 index 000000000..cf10b9eea --- /dev/null +++ b/packages/plugins/auth/src/client/locale/zh-CN.ts @@ -0,0 +1,10 @@ +const locale = { + 'Auth Type': '认证类型', + Authenticators: '认证器', + Authentication: '用户认证', + 'Sign in via email': '邮箱登录', + 'Not allowed to sign up': '禁止注册', + 'Allow to sign up': '允许注册', +}; + +export default locale; diff --git a/packages/plugins/auth/src/client/settings/Authenticator.tsx b/packages/plugins/auth/src/client/settings/Authenticator.tsx new file mode 100644 index 000000000..ffb75fda1 --- /dev/null +++ b/packages/plugins/auth/src/client/settings/Authenticator.tsx @@ -0,0 +1,95 @@ +import { + ActionContext, + SchemaComponent, + useAPIClient, + useActionContext, + useAsyncData, + useRequest, +} from '@nocobase/client'; +import { Card } from 'antd'; +import React, { useState } from 'react'; +import { authenticatorsSchema, createFormSchema } from './schemas/authenticators'; +import { Button, Dropdown } from 'antd'; +import { PlusOutlined, DownOutlined } from '@ant-design/icons'; +import { AuthTypeContext, AuthTypesContext, useAuthTypes } from './authType'; +import { useValuesFromOptions, Options } from './Options'; +import { useTranslation } from 'react-i18next'; + +const useCloseAction = () => { + const { setVisible } = useActionContext(); + return { + async run() { + setVisible(false); + }, + }; +}; + +const AddNew = () => { + const { t } = useTranslation(); + const [visible, setVisible] = useState(false); + const [type, setType] = useState(''); + const types = useAuthTypes(); + const items = types.map((item) => ({ + ...item, + onClick: () => { + setVisible(true); + setType(item.value); + }, + })); + + return ( + + + + + + + + + ); +}; + +// Disable delete button when there is only one authenticator +const useCanNotDelete = () => { + const { data } = useAsyncData(); + // return data?.meta?.count === 1; + return false; +}; + +export const Authenticator = () => { + const [types, setTypes] = useState([]); + const api = useAPIClient(); + useRequest( + () => + api + .resource('authenticators') + .listTypes() + .then((res) => { + const types = res?.data?.data || []; + return types.map((type: string) => ({ + key: type, + label: type, + value: type, + })); + }), + { + onSuccess: (types) => { + setTypes(types); + }, + }, + ); + + return ( + + + + + + ); +}; diff --git a/packages/plugins/auth/src/client/settings/Options.tsx b/packages/plugins/auth/src/client/settings/Options.tsx new file mode 100644 index 000000000..909d90c60 --- /dev/null +++ b/packages/plugins/auth/src/client/settings/Options.tsx @@ -0,0 +1,35 @@ +import { useRequest, useRecord, useActionContext } from '@nocobase/client'; +import { useEffect } from 'react'; +import { useOptionsComponent } from '@nocobase/client'; +import { observer, useForm } from '@formily/react'; + +export const useValuesFromOptions = (options) => { + const record = useRecord(); + const result = useRequest( + () => + Promise.resolve({ + data: { + ...record.options, + }, + }), + { + ...options, + manual: true, + }, + ); + const { run } = result; + const ctx = useActionContext(); + useEffect(() => { + if (ctx.visible) { + run(); + } + }, [ctx.visible, run]); + return result; +}; + +export const Options = observer(() => { + const form = useForm(); + const record = useRecord(); + const component = useOptionsComponent(form.values.authType || record.authType); + return component; +}); diff --git a/packages/plugins/auth/src/client/settings/authType.ts b/packages/plugins/auth/src/client/settings/authType.ts new file mode 100644 index 000000000..040440564 --- /dev/null +++ b/packages/plugins/auth/src/client/settings/authType.ts @@ -0,0 +1,18 @@ +import { createContext, useContext } from 'react'; + +export const AuthTypeContext = createContext<{ + type: string; +}>({ type: '' }); + +export const AuthTypesContext = createContext<{ + types: { + key: string; + label: string; + value: string; + }[]; +}>({ types: [] }); + +export const useAuthTypes = () => { + const { types } = useContext(AuthTypesContext); + return types; +}; diff --git a/packages/plugins/auth/src/client/settings/schemas/authenticators.ts b/packages/plugins/auth/src/client/settings/schemas/authenticators.ts new file mode 100644 index 000000000..c120c21a0 --- /dev/null +++ b/packages/plugins/auth/src/client/settings/schemas/authenticators.ts @@ -0,0 +1,402 @@ +import { uid } from '@formily/shared'; +import { ISchema } from '@formily/react'; +import { useAPIClient, useActionContext, useRequest } from '@nocobase/client'; +import { useContext } from 'react'; +import { AuthTypeContext } from '../authType'; +import { message } from 'antd'; +import { useTranslation } from 'react-i18next'; + +const collection = { + name: 'authenticators', + sortable: true, + fields: [ + { + name: 'id', + type: 'string', + interface: 'id', + }, + { + interface: 'input', + type: 'string', + name: 'name', + uiSchema: { + type: 'string', + title: '{{t("Name")}}', + 'x-component': 'Input', + required: true, + }, + }, + { + interface: 'input', + type: 'string', + name: 'authType', + uiSchema: { + type: 'string', + title: '{{t("Auth Type", {ns: "auth"})}}', + 'x-component': 'Select', + required: true, + }, + }, + { + interface: 'input', + type: 'string', + name: 'title', + uiSchema: { + type: 'string', + title: '{{t("Title")}}', + 'x-component': 'Input', + }, + }, + { + interface: 'textarea', + type: 'string', + name: 'description', + uiSchema: { + type: 'string', + title: '{{t("Description")}}', + 'x-component': 'Input', + }, + }, + { + type: 'boolean', + name: 'enabled', + uiSchema: { + type: 'boolean', + title: '{{t("Enabled")}}', + 'x-component': 'Checkbox', + }, + }, + ], +}; + +export const createFormSchema: ISchema = { + type: 'object', + properties: { + drawer: { + type: 'void', + 'x-component': 'Action.Drawer', + 'x-decorator': 'Form', + 'x-decorator-props': { + useValues(options) { + const ctx = useActionContext(); + const { type: authType } = useContext(AuthTypeContext); + return useRequest( + () => + Promise.resolve({ + data: { + name: `s_${uid()}`, + authType, + }, + }), + { ...options, refreshDeps: [ctx.visible] }, + ); + }, + }, + title: '{{t("Add new")}}', + properties: { + name: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + authType: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + 'x-component-props': { + options: '{{ types }}', + }, + }, + title: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + description: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + enabled: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + options: { + type: 'object', + 'x-component': 'Options', + }, + footer: { + type: 'void', + 'x-component': 'Action.Drawer.Footer', + properties: { + cancel: { + title: '{{t("Cancel")}}', + 'x-component': 'Action', + 'x-component-props': { + useAction: '{{ cm.useCancelAction }}', + }, + }, + submit: { + title: '{{t("Submit")}}', + 'x-component': 'Action', + 'x-component-props': { + type: 'primary', + useAction: '{{ cm.useCreateAction }}', + }, + }, + }, + }, + }, + }, + }, +}; + +export const authenticatorsSchema: ISchema = { + type: 'void', + name: 'authenticators', + 'x-decorator': 'ResourceActionProvider', + 'x-decorator-props': { + collection, + resourceName: 'authenticators', + dragSort: true, + request: { + resource: 'authenticators', + action: 'list', + params: { + pageSize: 50, + sort: 'sort', + appends: [], + }, + }, + }, + 'x-component': 'CollectionProvider', + 'x-component-props': { + collection, + }, + properties: { + actions: { + type: 'void', + 'x-component': 'ActionBar', + 'x-component-props': { + style: { + marginBottom: 16, + }, + }, + properties: { + delete: { + type: 'void', + title: '{{t("Delete")}}', + 'x-component': 'Action', + 'x-component-props': { + useAction: '{{ cm.useBulkDestroyAction }}', + confirm: { + title: "{{t('Delete')}}", + content: "{{t('Are you sure you want to delete it?')}}", + }, + }, + }, + create: { + type: 'void', + title: '{{t("Add new")}}', + 'x-component': 'AddNew', + 'x-component-props': { + type: 'primary', + }, + }, + }, + }, + table: { + type: 'void', + 'x-uid': 'input', + 'x-component': 'Table.Void', + 'x-component-props': { + rowKey: 'id', + rowSelection: { + type: 'checkbox', + }, + useDataSource: '{{ cm.useDataSourceFromRAC }}', + useAction() { + const api = useAPIClient(); + const { t } = useTranslation(); + return { + async move(from, to) { + await api.resource('authenticators').move({ + sourceId: from.id, + targetId: to.id, + }); + message.success(t('Saved successfully'), 0.2); + }, + }; + }, + }, + properties: { + id: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + id: { + type: 'number', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + }, + }, + name: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + name: { + type: 'string', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + }, + }, + authType: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + authType: { + type: 'string', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + }, + }, + title: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + title: { + type: 'string', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + }, + }, + description: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + description: { + type: 'boolean', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + }, + }, + enabled: { + type: 'void', + 'x-decorator': 'Table.Column.Decorator', + 'x-component': 'Table.Column', + properties: { + enabled: { + type: 'boolean', + 'x-component': 'CollectionField', + 'x-read-pretty': true, + }, + }, + }, + actions: { + type: 'void', + title: '{{t("Actions")}}', + 'x-component': 'Table.Column', + properties: { + actions: { + type: 'void', + 'x-component': 'Space', + 'x-component-props': { + split: '|', + }, + properties: { + update: { + type: 'void', + title: '{{t("Configure")}}', + 'x-component': 'Action.Link', + 'x-component-props': { + type: 'primary', + }, + properties: { + drawer: { + type: 'void', + 'x-component': 'Action.Drawer', + 'x-decorator': 'Form', + 'x-decorator-props': { + useValues: '{{ cm.useValuesFromRecord }}', + }, + title: '{{t("Configure")}}', + properties: { + name: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + authType: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + 'x-component-props': { + options: '{{ types }}', + }, + }, + title: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + description: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + enabled: { + 'x-component': 'CollectionField', + 'x-decorator': 'FormItem', + }, + options: { + type: 'object', + 'x-component': 'Options', + }, + footer: { + type: 'void', + 'x-component': 'Action.Drawer.Footer', + properties: { + cancel: { + title: '{{t("Cancel")}}', + 'x-component': 'Action', + 'x-component-props': { + useAction: '{{ cm.useCancelAction }}', + }, + }, + submit: { + title: '{{t("Submit")}}', + 'x-component': 'Action', + 'x-component-props': { + type: 'primary', + useAction: '{{ cm.useUpdateAction }}', + }, + }, + }, + }, + }, + }, + }, + }, + delete: { + type: 'void', + title: '{{ t("Delete") }}', + 'x-component': 'Action.Link', + 'x-component-props': { + confirm: { + title: "{{t('Delete record')}}", + content: "{{t('Are you sure you want to delete it?')}}", + }, + useAction: '{{cm.useDestroyAction}}', + }, + 'x-disabled': '{{ useCanNotDelete() }}', + }, + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/packages/plugins/auth/src/index.ts b/packages/plugins/auth/src/index.ts new file mode 100644 index 000000000..cdb08add1 --- /dev/null +++ b/packages/plugins/auth/src/index.ts @@ -0,0 +1 @@ +export { default, AuthModel } from './server'; diff --git a/packages/plugins/auth/src/preset.ts b/packages/plugins/auth/src/preset.ts new file mode 100644 index 000000000..13a87b304 --- /dev/null +++ b/packages/plugins/auth/src/preset.ts @@ -0,0 +1,4 @@ +export const presetAuthType = 'Email/Password'; +export const presetAuthenticator = 'basic'; + +export const namespace = require('../package.json').name; diff --git a/packages/plugins/auth/src/server/__tests__/actions.test.ts b/packages/plugins/auth/src/server/__tests__/actions.test.ts new file mode 100644 index 000000000..690faa82a --- /dev/null +++ b/packages/plugins/auth/src/server/__tests__/actions.test.ts @@ -0,0 +1,142 @@ +import Database, { Repository } from '@nocobase/database'; +import { mockServer, MockServer } from '@nocobase/test'; +import AuthPlugin from '../'; +import UsersPlugin from '@nocobase/plugin-users'; + +describe('actions', () => { + describe('authenticators', () => { + let app: MockServer; + let db: Database; + let repo: Repository; + let agent; + + beforeAll(async () => { + app = mockServer(); + app.plugin(AuthPlugin); + await app.loadAndInstall({ clean: true }); + db = app.db; + repo = db.getRepository('authenticators'); + + agent = app.agent(); + }); + + afterEach(async () => { + await repo.destroy({ + truncate: true, + }); + }); + + afterAll(async () => { + await db.close(); + }); + + it('should list authenticator types', async () => { + const res = await agent.resource('authenticators').listTypes(); + expect(res.body.data).toEqual(['Email/Password']); + }); + + it('should return enabled authenticators with public options', async () => { + await repo.destroy({ + truncate: true, + }); + await repo.createMany({ + records: [ + { name: 'test', authType: 'testType', enabled: true, options: { public: { test: 1 }, private: { test: 2 } } }, + { name: 'test2', authType: 'testType' }, + ], + }); + const res = await agent.resource('authenticators').publicList(); + expect(res.body.data.length).toBe(1); + expect(res.body.data[0].name).toBe('test'); + }); + + it('should keep at least one authenticator', async () => { + await repo.createMany({ + records: [{ name: 'test', authType: 'testType', enabled: true }], + }); + const res = await agent.resource('authenticators').destroy(); + expect(res.statusCode).toBe(400); + expect(await repo.count()).toBe(1); + }); + + it('shoud enable at least one authenticator', async () => { + await repo.createMany({ + records: [{ name: 'test', authType: 'testType', enabled: true }], + }); + const res = await agent.resource('authenticators').update({ + filterByTk: 1, + values: { + enabled: false, + }, + }); + expect(res.statusCode).toBe(400); + expect(await repo.count()).toBe(1); + }); + }); + + describe('auth', () => { + let app: MockServer; + let db: Database; + let agent; + + beforeAll(async () => { + app = mockServer(); + process.env.INIT_ROOT_EMAIL = 'test@nocobase.com'; + process.env.INIT_ROOT_PASSWORD = '123456'; + process.env.INIT_ROOT_NICKNAME = 'Test'; + app.plugin(AuthPlugin); + app.plugin(UsersPlugin, { name: 'users' }); + await app.loadAndInstall({ clean: true }); + db = app.db; + agent = app.agent(); + }); + + afterAll(async () => { + await db.close(); + }); + + it('should sign in with email and password', async () => { + let res = await agent.resource('auth').check(); + expect(res.body.data.id).toBeUndefined(); + + res = await agent.post('/auth:signIn').set({ 'X-Authenticator': 'basic' }).send({ + email: process.env.INIT_ROOT_EMAIL, + password: process.env.INIT_ROOT_PASSWORD, + }); + expect(res.statusCode).toEqual(200); + const data = res.body.data; + const token = data.token; + expect(token).toBeDefined(); + + res = await agent.get('/auth:check').set({ Authorization: `Bearer ${token}`, 'X-Authenticator': 'basic' }); + expect(res.body.data.id).toBeDefined(); + }); + + it('should disable sign up', async () => { + let res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({ + email: 'new', + password: 'new', + }); + expect(res.statusCode).toEqual(200); + + const repo = db.getRepository('authenticators'); + await repo.update({ + filter: { + name: 'basic', + }, + values: { + options: { + public: { + allowSignUp: false, + }, + }, + }, + }); + res = await agent.post('/auth:signUp').set({ 'X-Authenticator': 'basic' }).send({ + email: process.env.INIT_ROOT_EMAIL, + password: process.env.INIT_ROOT_PASSWORD, + }); + expect(res.statusCode).toEqual(403); + }); + }); +}); diff --git a/packages/plugins/auth/src/server/actions/auth.ts b/packages/plugins/auth/src/server/actions/auth.ts new file mode 100644 index 000000000..ed204a5d9 --- /dev/null +++ b/packages/plugins/auth/src/server/actions/auth.ts @@ -0,0 +1,20 @@ +import { Context, Next } from '@nocobase/actions'; + +export default { + lostPassword: async (ctx: Context, next: Next) => { + ctx.body = await ctx.auth.lostPassword(); + await next(); + }, + resetPassword: async (ctx: Context, next: Next) => { + ctx.body = await ctx.auth.resetPassword(); + await next(); + }, + getUserByResetToken: async (ctx: Context, next: Next) => { + ctx.body = await ctx.auth.getUserByResetToken(); + await next(); + }, + changePassword: async (ctx: Context, next: Next) => { + ctx.body = await ctx.auth.changePassword(); + await next(); + }, +}; diff --git a/packages/plugins/auth/src/server/actions/authenticators.ts b/packages/plugins/auth/src/server/actions/authenticators.ts new file mode 100644 index 000000000..c1f384ab5 --- /dev/null +++ b/packages/plugins/auth/src/server/actions/authenticators.ts @@ -0,0 +1,85 @@ +import { Context, Next } from '@nocobase/actions'; +import { namespace } from '../../preset'; +import { Model, Repository } from '@nocobase/database'; + +async function checkCount(repository: Repository, id: number) { + // TODO(yangqia): This is a temporary solution, may cause concurrency problem. + const count = await repository.count({ + filter: { + enabled: true, + id: { + $ne: id, + }, + }, + }); + if (count <= 0) { + throw new Error('Please keep and enable at least one authenticator'); + } +} + +export default { + listTypes: async (ctx: Context, next: Next) => { + ctx.body = ctx.app.authManager.listTypes(); + await next(); + }, + publicList: async (ctx: Context, next: Next) => { + const repo = ctx.db.getRepository('authenticators'); + const authenticators = await repo.find({ + fields: ['name', 'authType', 'title', 'options', 'sort'], + filter: { + enabled: true, + }, + sort: 'sort', + }); + ctx.body = authenticators.map((authenticator: Model) => ({ + name: authenticator.name, + authType: authenticator.authType, + title: authenticator.title, + options: authenticator.options?.public || {}, + })); + await next(); + }, + destroy: async (ctx: Context, next: Next) => { + const repository = ctx.db.getRepository('authenticators'); + const { filterByTk, filter } = ctx.action.params; + try { + await checkCount(repository, filterByTk); + } catch (err) { + ctx.throw(400, ctx.t(err.message, { ns: namespace })); + } + const instance = await repository.destroy({ + filter, + filterByTk, + context: ctx, + }); + + ctx.body = instance; + await next(); + }, + update: async (ctx: Context, next: Next) => { + const repository = ctx.db.getRepository('authenticators'); + const { forceUpdate, filterByTk, values, whitelist, blacklist, filter, updateAssociationValues } = + ctx.action.params; + + if (!values.enabled) { + try { + await checkCount(repository, values.id); + } catch (err) { + ctx.throw(400, ctx.t(err.message, { ns: namespace })); + } + } + + ctx.body = await repository.update({ + filterByTk, + values, + whitelist, + blacklist, + filter, + updateAssociationValues, + context: ctx, + forceUpdate, + }); + + await next(); + }, +}; diff --git a/packages/plugins/auth/src/server/basic-auth.ts b/packages/plugins/auth/src/server/basic-auth.ts new file mode 100644 index 000000000..a02ad684c --- /dev/null +++ b/packages/plugins/auth/src/server/basic-auth.ts @@ -0,0 +1,128 @@ +import { AuthConfig, BaseAuth } from '@nocobase/auth'; +import { namespace } from '../preset'; +import { PasswordField } from '@nocobase/database'; +import crypto from 'crypto'; + +export class BasicAuth extends BaseAuth { + constructor(config: AuthConfig) { + const userCollection = config.ctx.db.getCollection('users'); + super({ ...config, userCollection }); + } + + async validate() { + const ctx = this.ctx; + const { uniqueField = 'email', values } = ctx.action.params; + + if (!values[uniqueField]) { + ctx.throw(400, ctx.t('Please fill in your email address', { ns: namespace })); + } + const user = await this.userCollection.repository.findOne({ + where: { + [uniqueField]: values[uniqueField], + }, + }); + + if (!user) { + ctx.throw(401, ctx.t('The email is incorrect, please re-enter', { ns: namespace })); + } + + const field = this.userCollection.getField('password'); + const valid = await field.verify(values.password, user.password); + if (!valid) { + ctx.throw(401, ctx.t('The password is incorrect, please re-enter', { ns: namespace })); + } + return user; + } + + async signUp() { + const ctx = this.ctx; + const options = this.authenticator.options?.public || {}; + if (!options.allowSignUp) { + ctx.throw(403, ctx.t('Not allowed to sign up', { ns: namespace })); + } + const User = ctx.db.getRepository('users'); + const { values } = ctx.action.params; + const user = await User.create({ values }); + return user; + } + + async lostPassword() { + const ctx = this.ctx; + const { + values: { email }, + } = ctx.action.params; + if (!email) { + ctx.throw(400, ctx.t('Please fill in your email address', { ns: namespace })); + } + const user = await this.userCollection.repository.findOne({ + where: { + email, + }, + }); + if (!user) { + ctx.throw(401, ctx.t('The email is incorrect, please re-enter', { ns: namespace })); + } + user.resetToken = crypto.randomBytes(20).toString('hex'); + await user.save(); + return user; + } + + async resetPassword() { + const ctx = this.ctx; + const { + values: { email, password, resetToken }, + } = ctx.action.params; + const user = await this.userCollection.repository.findOne({ + where: { + email, + resetToken, + }, + }); + if (!user) { + ctx.throw(404); + } + user.token = null; + user.resetToken = null; + user.password = password; + await user.save(); + return user; + } + + async getUserByResetToken() { + const ctx = this.ctx; + const { token } = ctx.action.params; + const user = await this.userCollection.repository.findOne({ + where: { + resetToken: token, + }, + }); + if (!user) { + ctx.throw(401); + } + return user; + } + + async changePassword() { + const ctx = this.ctx; + const { + values: { oldPassword, newPassword }, + } = ctx.action.params; + const currentUser = ctx.auth.user; + if (!currentUser) { + ctx.throw(401); + } + const user = await this.userCollection.repository.findOne({ + where: { + email: currentUser.email, + }, + }); + const pwd = this.userCollection.getField('password'); + const isValid = await pwd.verify(oldPassword, user.password); + if (!isValid) { + ctx.throw(401, ctx.t('The password is incorrect, please re-enter', { ns: namespace })); + } + user.password = newPassword; + await user.save(); + return currentUser; + } +} diff --git a/packages/plugins/oidc/src/server/collections/.gitkeep b/packages/plugins/auth/src/server/collections/.gitkeep similarity index 100% rename from packages/plugins/oidc/src/server/collections/.gitkeep rename to packages/plugins/auth/src/server/collections/.gitkeep diff --git a/packages/plugins/auth/src/server/collections/authenticators.ts b/packages/plugins/auth/src/server/collections/authenticators.ts new file mode 100644 index 000000000..da968a463 --- /dev/null +++ b/packages/plugins/auth/src/server/collections/authenticators.ts @@ -0,0 +1,97 @@ +import { CollectionOptions } from '@nocobase/database'; + +/** + * Collection for extended authentication methods, + */ +export default { + namespace: 'auth.auth', + duplicator: 'optional', + name: 'authenticators', + sortable: true, + title: '{{t("Authenticators")}}', + model: 'AuthModel', + createdBy: true, + updatedBy: true, + logging: true, + fields: [ + { + name: 'id', + type: 'bigInt', + autoIncrement: true, + primaryKey: true, + allowNull: false, + interface: 'id', + }, + { + interface: 'input', + type: 'string', + name: 'name', + allowNull: false, + unique: true, + uiSchema: { + type: 'string', + title: '{{t("Name")}}', + 'x-component': 'Input', + required: true, + }, + }, + { + interface: 'input', + type: 'string', + name: 'authType', + allowNull: false, + uiSchema: { + type: 'string', + title: '{{t("Auth Type")}}', + 'x-component': 'Input', + required: true, + }, + }, + { + interface: 'input', + type: 'string', + name: 'title', + uiSchema: { + type: 'string', + title: '{{t("Title")}}', + 'x-component': 'Input', + }, + }, + { + interface: 'textarea', + type: 'string', + name: 'description', + allowNull: false, + defaultValue: '', + uiSchema: { + type: 'string', + title: '{{t("Description")}}', + 'x-component': 'Input', + required: true, + }, + }, + { + type: 'json', + name: 'options', + allowNull: false, + defaultValue: {}, + }, + { + type: 'boolean', + name: 'enabled', + defaultValue: false, + }, + { + interface: 'm2m', + type: 'belongsToMany', + name: 'users', + target: 'users', + foreignKey: 'authenticator', + otherKey: 'userId', + onDelete: 'CASCADE', + sourceKey: 'name', + targetKey: 'id', + through: 'usersAuthenticators', + }, + ], +} as CollectionOptions; diff --git a/packages/plugins/auth/src/server/collections/users-authenticators.ts b/packages/plugins/auth/src/server/collections/users-authenticators.ts new file mode 100644 index 000000000..395092da9 --- /dev/null +++ b/packages/plugins/auth/src/server/collections/users-authenticators.ts @@ -0,0 +1,73 @@ +import { CollectionOptions } from '@nocobase/database'; + +/** + * Collection for user information of extended authentication methods, + * such as saml, oicd, oauth, sms, etc. + */ +export default { + namespace: 'auth.auth', + duplicator: { + dumpable: 'optional', + /** + * When dump this collection, the users collection is required to be dumped. + */ + with: 'users', + }, + name: 'usersAuthenticators', + title: '{{t("Users Authenticators")}}', + model: 'UserAuthModel', + createdBy: true, + updatedBy: true, + logging: true, + fields: [ + /** + * uuid: + * Unique user id of the authentication method, such as wechat openid, phone number, etc. + */ + { + name: 'uuid', + interface: 'input', + type: 'string', + allowNull: false, + uiSchema: { + type: 'string', + title: '{{t("UUID")}}', + 'x-component': 'Input', + required: true, + }, + }, + { + interface: 'input', + type: 'string', + name: 'nickname', + allowNull: false, + defaultValue: '', + uiSchema: { + type: 'string', + title: '{{t("Nickname")}}', + 'x-component': 'Input', + }, + }, + { + interface: 'attachment', + type: 'string', + name: 'avatar', + allowNull: false, + defaultValue: '', + uiSchema: { + type: 'string', + title: '{{t("Avatar")}}', + 'x-component': 'Upload', + }, + }, + /** + * meta: + * Metadata, some other information of the authentication method. + */ + { + type: 'json', + name: 'meta', + defaultValue: {}, + }, + ], +} as CollectionOptions; diff --git a/packages/plugins/auth/src/server/index.ts b/packages/plugins/auth/src/server/index.ts new file mode 100644 index 000000000..78c0e3e56 --- /dev/null +++ b/packages/plugins/auth/src/server/index.ts @@ -0,0 +1,2 @@ +export { default } from './plugin'; +export { AuthModel } from './model/authenticator'; diff --git a/packages/plugins/auth/src/server/locale/en-US.ts b/packages/plugins/auth/src/server/locale/en-US.ts new file mode 100644 index 000000000..8da957a8b --- /dev/null +++ b/packages/plugins/auth/src/server/locale/en-US.ts @@ -0,0 +1,10 @@ +export default { + 'The email is incorrect, please re-enter': 'The email is incorrect, please re-enter', + 'Please fill in your email address': 'Please fill in your email address', + 'The password is incorrect, please re-enter': 'The password is incorrect, please re-enter', + 'Not a valid cellphone number, please re-enter': 'Not a valid cellphone number, please re-enter', + 'The phone number has been registered, please login directly': + 'The phone number has been registered, please login directly', + 'The phone number is not registered, please register first': + 'The phone number is not registered, please register first', +}; diff --git a/packages/plugins/auth/src/server/locale/index.ts b/packages/plugins/auth/src/server/locale/index.ts new file mode 100644 index 000000000..03bcd4fce --- /dev/null +++ b/packages/plugins/auth/src/server/locale/index.ts @@ -0,0 +1,3 @@ +export { default as enUS } from './en-US'; +export { default as zhCN } from './zh-CN'; +export { default as ptBR } from './pt-BR'; diff --git a/packages/plugins/auth/src/server/locale/ja-JP.ts b/packages/plugins/auth/src/server/locale/ja-JP.ts new file mode 100644 index 000000000..5af98b70f --- /dev/null +++ b/packages/plugins/auth/src/server/locale/ja-JP.ts @@ -0,0 +1,4 @@ +export default { + 'Please fill in your email address': 'メールアドレスを入力してください', + 'The password is incorrect, please re-enter': 'パスワードが正しくありません。再度入力してください。', +}; diff --git a/packages/plugins/auth/src/server/locale/pt-BR.ts b/packages/plugins/auth/src/server/locale/pt-BR.ts new file mode 100644 index 000000000..d6b5f77ec --- /dev/null +++ b/packages/plugins/auth/src/server/locale/pt-BR.ts @@ -0,0 +1,10 @@ +export default { + 'The email is incorrect, please re-enter': 'O e-mail está incorreto, por favor, digite novamente', + 'Please fill in your email address': 'Por favor, preencha o seu endereço de e-mail', + 'The password is incorrect, please re-enter': 'A senha está incorreta, por favor, digite novamente', + 'Not a valid cellphone number, please re-enter': 'Número de celular inválido, por favor, digite novamente', + 'The phone number has been registered, please login directly': + 'O número de celular já está registrado, por favor, faça login diretamente', + 'The phone number is not registered, please register first': + 'O número de celular não está registrado, por favor, registre-se primeiro', +}; diff --git a/packages/plugins/auth/src/server/locale/zh-CN.ts b/packages/plugins/auth/src/server/locale/zh-CN.ts new file mode 100644 index 000000000..8265a9444 --- /dev/null +++ b/packages/plugins/auth/src/server/locale/zh-CN.ts @@ -0,0 +1,9 @@ +export default { + 'The email is incorrect, please re-enter': '邮箱有误,请重新输入', + 'Please fill in your email address': '请填写邮箱', + 'The password is incorrect, please re-enter': '密码有误,请重新输入', + 'Not a valid cellphone number, please re-enter': '不是有效的手机号,请重新输入', + 'The phone number has been registered, please login directly': '手机号已注册,请直接登录', + 'The phone number is not registered, please register first': '手机号未注册,请先注册', + 'Please keep and enable at least one authenticator': '请至少保留并启用一个认证器', +}; diff --git a/packages/plugins/auth/src/server/migrations/20230506152253-basic-authenticator.ts b/packages/plugins/auth/src/server/migrations/20230506152253-basic-authenticator.ts new file mode 100644 index 000000000..621d9fb75 --- /dev/null +++ b/packages/plugins/auth/src/server/migrations/20230506152253-basic-authenticator.ts @@ -0,0 +1,22 @@ +import { Migration } from '@nocobase/server'; +import { presetAuthType, presetAuthenticator } from '../../preset'; + +export default class AddBasicAuthMigration extends Migration { + async up() { + const repo = this.context.db.getRepository('authenticators'); + const existed = await repo.count(); + if (existed) { + return; + } + await repo.create({ + values: { + name: presetAuthenticator, + authType: presetAuthType, + description: 'Sign in with email and password.', + enabled: true, + }, + }); + } + + async down() {} +} diff --git a/packages/plugins/auth/src/server/migrations/20230607174500-update-basic.ts b/packages/plugins/auth/src/server/migrations/20230607174500-update-basic.ts new file mode 100644 index 000000000..881457b1b --- /dev/null +++ b/packages/plugins/auth/src/server/migrations/20230607174500-update-basic.ts @@ -0,0 +1,25 @@ +import { Migration } from '@nocobase/server'; +import { presetAuthenticator } from '../../preset'; + +export default class UpdateBasicAuthMigration extends Migration { + async up() { + const SystemSetting = this.context.db.getRepository('systemSettings'); + const setting = await SystemSetting.findOne(); + const allowSignUp = setting.get('allowSignUp') ? true : false; + const repo = this.context.db.getRepository('authenticators'); + await repo.update({ + values: { + options: { + public: { + allowSignUp, + }, + }, + }, + filter: { + name: presetAuthenticator, + }, + }); + } + + async down() {} +} diff --git a/packages/plugins/auth/src/server/model/authenticator.ts b/packages/plugins/auth/src/server/model/authenticator.ts new file mode 100644 index 000000000..cba25805b --- /dev/null +++ b/packages/plugins/auth/src/server/model/authenticator.ts @@ -0,0 +1,48 @@ +import { Database, Model } from '@nocobase/database'; + +export class AuthModel extends Model { + async findUser(uuid: string) { + let user: Model; + const users = await this.getUsers({ + through: { + where: { uuid }, + }, + }); + if (users.length) { + user = users[0]; + return user; + } + } + + async newUser(uuid: string, values?: any) { + let user: Model; + const db: Database = (this.constructor as any).database; + await this.sequelize.transaction(async (transaction) => { + // Create a new user if not exists + user = await this.createUser( + values || { + nickname: uuid, + }, + { + through: { + uuid: uuid, + }, + transaction, + }, + ); + await db.emitAsync(`users.afterCreateWithAssociations`, user, { + transaction, + }); + }); + return user; + } + + async findOrCreateUser(uuid: string, userValues?: any) { + const user = await this.findUser(uuid); + if (user) { + return user; + } + + return await this.newUser(uuid, userValues); + } +} diff --git a/packages/plugins/auth/src/server/plugin.ts b/packages/plugins/auth/src/server/plugin.ts new file mode 100644 index 000000000..cd609b4b0 --- /dev/null +++ b/packages/plugins/auth/src/server/plugin.ts @@ -0,0 +1,92 @@ +import { InstallOptions, Plugin } from '@nocobase/server'; +import { resolve } from 'path'; +import { BasicAuth } from './basic-auth'; +import { presetAuthType, presetAuthenticator } from '../preset'; +import authActions from './actions/auth'; +import authenticatorsActions from './actions/authenticators'; +import { enUS, zhCN } from './locale'; +import { namespace } from '../preset'; +import { AuthModel } from './model/authenticator'; +import { Model } from '@nocobase/database'; + +export class AuthPlugin extends Plugin { + afterAdd() {} + + async beforeLoad() { + this.app.i18n.addResources('zh-CN', namespace, zhCN); + this.app.i18n.addResources('en-US', namespace, enUS); + + this.app.db.registerModels({ AuthModel }); + } + + async load() { + // Set up database + await this.db.import({ + directory: resolve(__dirname, 'collections'), + }); + this.db.addMigrations({ + namespace: 'auth', + directory: resolve(__dirname, 'migrations'), + context: { + plugin: this, + }, + }); + // Set up auth manager and register preset auth type + this.app.authManager.setStorer({ + get: async (name: string) => { + const repo = this.db.getRepository('authenticators'); + const authenticators = await repo.find({ filter: { enabled: true } }); + const authenticator = authenticators.find((authenticator: Model) => authenticator.name === name); + return authenticator || authenticators[0]; + }, + }); + this.app.authManager.registerTypes(presetAuthType, { + auth: BasicAuth, + }); + // Register actions + Object.entries(authActions).forEach(([action, handler]) => + this.app.resourcer.registerAction(`auth:${action}`, handler), + ); + Object.entries(authenticatorsActions).forEach(([action, handler]) => + this.app.resourcer.registerAction(`authenticators:${action}`, handler), + ); + // Set up ACL + ['check', 'signIn', 'signUp'].forEach((action) => this.app.acl.allow('auth', action)); + ['signOut', 'changePassword'].forEach((action) => this.app.acl.allow('auth', action, 'loggedIn')); + this.app.acl.allow('authenticators', 'publicList'); + this.app.acl.registerSnippet({ + name: `pm.${this.name}.authenticators`, + actions: ['authenticators:*'], + }); + } + + async install(options?: InstallOptions) { + const repository = this.db.getRepository('authenticators'); + const exist = await repository.findOne({ filter: { name: presetAuthenticator } }); + if (exist) { + return; + } + + await repository.create({ + values: { + name: presetAuthenticator, + authType: presetAuthType, + description: 'Sign in with email and password.', + enabled: true, + options: { + public: { + allowSignUp: true, + }, + }, + }, + }); + } + + async afterEnable() {} + + async afterDisable() {} + + async remove() {} +} + +export default AuthPlugin; diff --git a/packages/plugins/oidc/package.json b/packages/plugins/oidc/package.json index 30ee1decf..6aa1c0175 100644 --- a/packages/plugins/oidc/package.json +++ b/packages/plugins/oidc/package.json @@ -2,17 +2,19 @@ "name": "@nocobase/plugin-oidc", "displayName": "OpenID Connect", "displayName.zh-CN": "OpenID Connect", - "description": " provide OIDC authentication and authorization functionality for applications or authentication systems.", - "description.zh-CN": "在为应用程序或身份验证系统提供OIDC认证和授权功能。", + "description": "Provides OIDC authentication and authorization functionality for applications or authentication systems.", + "description.zh-CN": "为应用程序或身份验证系统提供OIDC认证和授权功能。", "version": "0.9.4-alpha.2", "license": "AGPL-3.0", "main": "./lib/index.js", "types": "./lib/index.d.ts", "devDependencies": { + "@nocobase/auth": "0.9.4-alpha.2", "@nocobase/server": "0.9.4-alpha.2", "@nocobase/test": "0.9.4-alpha.2" }, "dependencies": { - "openid-client": "^5.3.0" + "nanoid": "3.3.4", + "openid-client": "^5.4.2" } -} +} \ No newline at end of file diff --git a/packages/plugins/oidc/src/client/OIDCList.tsx b/packages/plugins/oidc/src/client/OIDCButton.tsx similarity index 62% rename from packages/plugins/oidc/src/client/OIDCList.tsx rename to packages/plugins/oidc/src/client/OIDCButton.tsx index 9c9f41761..725c344cb 100644 --- a/packages/plugins/oidc/src/client/OIDCList.tsx +++ b/packages/plugins/oidc/src/client/OIDCButton.tsx @@ -1,6 +1,6 @@ import { LoginOutlined } from '@ant-design/icons'; import { css } from '@emotion/css'; -import { useAPIClient, useRedirect, useRequest } from '@nocobase/client'; +import { Authenticator, useAPIClient, useRedirect } from '@nocobase/client'; import { useMemoizedFn } from 'ahooks'; import { Button, Space } from 'antd'; import React, { useEffect, useState } from 'react'; @@ -10,25 +10,20 @@ export interface OIDCProvider { title: string; } -export const OIDCList = () => { +export const OIDCButton = (props: { authenticator: Authenticator }) => { const [windowHandler, setWindowHandler] = useState(); const api = useAPIClient(); const redirect = useRedirect(); - const { data, loading } = useRequest({ - resource: 'oidc', - action: 'getEnabledProviders', - }); - /** * 打开登录弹出框 */ - const handleOpen = useMemoizedFn(async (item: OIDCProvider) => { + const handleOpen = async (name: string) => { const response = await api.request({ method: 'post', url: 'oidc:getAuthUrl', - data: { - clientId: item.clientId, + headers: { + 'X-Authenticator': name, }, }); @@ -44,16 +39,24 @@ export const OIDCList = () => { ); setWindowHandler(win); - }); + }; /** * 从弹出窗口,发消息回来进行登录 */ const handleOIDCLogin = useMemoizedFn(async (event: MessageEvent) => { - await api.auth.signIn(event.data, 'oidc'); - windowHandler.close(); - setWindowHandler(undefined); - redirect(); + const { state } = event.data; + const search = new URLSearchParams(state); + const authenticator = search.get('name'); + try { + await api.auth.signIn(event.data, authenticator); + redirect(); + } catch (err) { + console.error(err); + } finally { + windowHandler.close(); + setWindowHandler(undefined); + } }); /** @@ -61,12 +64,14 @@ export const OIDCList = () => { */ useEffect(() => { if (!windowHandler) return; + window.addEventListener('message', handleOIDCLogin); return () => { window.removeEventListener('message', handleOIDCLogin); }; - }, [windowHandler]); + }, [windowHandler, handleOIDCLogin]); + const authenticator = props.authenticator; return ( { display: flex; `} > - {data?.data?.map?.((item: OIDCProvider) => ( - - ))} + ); }; diff --git a/packages/plugins/oidc/src/client/OIDCPanel.tsx b/packages/plugins/oidc/src/client/OIDCPanel.tsx deleted file mode 100644 index 9d3852a11..000000000 --- a/packages/plugins/oidc/src/client/OIDCPanel.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; -import { uid } from '@formily/shared'; -import { SchemaComponent, useRecord } from '@nocobase/client'; -import { Card } from 'antd'; -import { oidcSchema } from './schemas/oidc'; -import { RedirectURLInput } from './RedirectURLInput'; -import { useOidcTranslation } from './locale'; - -const schema = { - type: 'object', - properties: { - [uid()]: oidcSchema, - }, -}; - -export const OIDCPanel = () => { - const { t } = useOidcTranslation(); - return ( - - - - ); -}; diff --git a/packages/plugins/oidc/src/client/Options.tsx b/packages/plugins/oidc/src/client/Options.tsx new file mode 100644 index 000000000..3f97a70c6 --- /dev/null +++ b/packages/plugins/oidc/src/client/Options.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { SchemaComponent } from '@nocobase/client'; +import { Card, message } from 'antd'; +import { CopyOutlined } from '@ant-design/icons'; +import { observer } from '@formily/react'; +import { FormItem, Input } from '@nocobase/client'; +import { useOidcTranslation } from './locale'; +import { ArrayItems } from '@formily/antd'; + +const schema = { + type: 'object', + properties: { + oidc: { + type: 'object', + properties: { + issuer: { + title: '{{t("Issuer")}}', + 'x-component': 'Input', + 'x-decorator': 'FormItem', + required: true, + }, + clientId: { + title: '{{t("Client ID")}}', + 'x-component': 'Input', + 'x-decorator': 'FormItem', + required: true, + }, + clientSecret: { + title: '{{t("Client Secret")}}', + 'x-component': 'Input', + 'x-decorator': 'FormItem', + required: true, + }, + scope: { + title: '{{t("scope")}}', + 'x-component': 'Input', + 'x-decorator': 'FormItem', + 'x-decorator-props': { + tooltip: '{{t("Default: openid profile email")}}', + }, + }, + http: { + title: '{{t("http")}}', + 'x-component': 'Checkbox', + 'x-decorator': 'FormItem', + }, + fieldMap: { + title: '{{t("Field Map")}}', + type: 'array', + 'x-decorator': 'FormItem', + 'x-component': 'ArrayItems', + items: { + type: 'object', + 'x-decorator': 'ArrayItems.Item', + properties: { + source: { + type: 'string', + title: '{{t("source")}}', + 'x-decorator': 'Editable', + 'x-component': 'Input', + 'x-component-props': { + placeholder: '{{t("source")}}', + }, + 'x-decorator-props': { + style: { + width: '45%', + }, + }, + }, + target: { + type: 'string', + title: '{{t("target")}}', + 'x-decorator': 'Editable', + 'x-component': 'Select', + 'x-component-props': { + placeholder: '{{t("target")}}', + }, + 'x-decorator-props': { + style: { + width: '45%', + }, + }, + enum: [ + { label: 'Nickname', value: 'nickname' }, + { label: 'Email', value: 'email' }, + { label: 'Phone', value: 'phone' }, + ], + }, + remove: { + type: 'void', + 'x-decorator': 'FormItem', + 'x-component': 'ArrayItems.Remove', + }, + }, + }, + properties: { + add: { + type: 'void', + title: 'Add', + 'x-component': 'ArrayItems.Addition', + }, + }, + }, + usage: { + type: 'void', + 'x-component': 'Usage', + }, + }, + }, + }, +}; + +const Usage = observer(() => { + const { t } = useOidcTranslation(); + + const { protocol, host } = window.location; + const url = `${protocol}//${host}/api/oidc:redirect`; + + const copy = (text: string) => { + navigator.clipboard.writeText(text); + message.success(t('Copied')); + }; + + return ( + + + copy(url)} />} /> + + + ); +}); + +export const Options = () => { + const { t } = useOidcTranslation(); + return ; +}; diff --git a/packages/plugins/oidc/src/client/RedirectURLInput.tsx b/packages/plugins/oidc/src/client/RedirectURLInput.tsx deleted file mode 100644 index 830393ace..000000000 --- a/packages/plugins/oidc/src/client/RedirectURLInput.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import { FormLayout } from '@formily/antd'; -import { Field } from '@formily/core'; -import { observer, useField, useForm } from '@formily/react'; -import { useEffect } from 'react'; -import { Input, useRecord } from '@nocobase/client'; - -export const RedirectURLInput = observer( - () => { - const form = useForm(); - const field = useField(); - const record = useRecord(); - - const clientId = form.values.clientId ?? record.clientId; - - useEffect(() => { - const { protocol, host } = window.location; - field.setValue(`${protocol}//${host}/api/oidc:redirect?clientId=${clientId}`); - }, [clientId]); - - return ( -
- - - -
- ); - }, - { displayName: 'RedirectURLInput' }, -); diff --git a/packages/plugins/oidc/src/client/index.tsx b/packages/plugins/oidc/src/client/index.tsx index bd0ed681f..4c73adcf6 100644 --- a/packages/plugins/oidc/src/client/index.tsx +++ b/packages/plugins/oidc/src/client/index.tsx @@ -1,38 +1,15 @@ -import { PluginManagerContext, SettingsCenterProvider, SigninPageExtensionProvider } from '@nocobase/client'; -import React, { useContext } from 'react'; -import { useOidcTranslation } from './locale'; -import { OIDCList } from './OIDCList'; -import { OIDCPanel } from './OIDCPanel'; +import { OptionsComponentProvider, SigninPageExtensionProvider } from '@nocobase/client'; +import React from 'react'; +import { OIDCButton } from './OIDCButton'; +import { authType } from '../constants'; +import { Options } from './Options'; export default function (props) { - const { t } = useOidcTranslation(); - const ctx = useContext(PluginManagerContext); return ( - - - - {props.children} - - + + + {props.children} + ); } diff --git a/packages/plugins/oidc/src/client/locale/zh-CN.ts b/packages/plugins/oidc/src/client/locale/zh-CN.ts index cd8ae7120..a2464288c 100644 --- a/packages/plugins/oidc/src/client/locale/zh-CN.ts +++ b/packages/plugins/oidc/src/client/locale/zh-CN.ts @@ -1,25 +1,8 @@ export default { - Issuer: 'Issuer', Enable: '启用', Actions: '操作', Delete: '删除', Edit: '编辑', - 'Button title': '登录按钮标题', - 'OIDC manager': 'OIDC 管理', - 'OIDC Providers': 'OIDC 身份提供者', - 'Provider name': '名称', - 'Client id': '客户端 id', - 'Client secret': '客户端 secret', - 'Openid configuration': '服务发现地址', - 'Authorization endpoint': '授权端点', - 'Access token endpoint': '令牌端点', - 'JWKS endpoint': 'JWKS 公钥端点', - 'Userinfo endpoint': '用户信息端点', - 'Redirect url': '重定向地址', - 'Logout endpoint': '登出端点', - 'Id token sign alg': 'Id token 签名算法', - 'Add provider': '添加', - 'Edit provider': '编辑', - 'Delete provider': '删除', - 'Sign in button name, which will be displayed on the sign in page': '登录按钮名称,将在登录页中显示', + Copied: '已复制', + 'Field Map': '字段映射', }; diff --git a/packages/plugins/oidc/src/client/schemas/oidc.ts b/packages/plugins/oidc/src/client/schemas/oidc.ts deleted file mode 100644 index 268d6548b..000000000 --- a/packages/plugins/oidc/src/client/schemas/oidc.ts +++ /dev/null @@ -1,451 +0,0 @@ -import { ISchema } from '@formily/react'; -import { useActionContext, useRequest } from '@nocobase/client'; -import { IDTOKEN_SIGN_ALG } from '../../server/shared/types'; - -const collection = { - name: 'oidcProviders', - fields: [ - { - type: 'string', - name: 'title', - interface: 'input', - uiSchema: { - title: '{{t("Provider name")}}', - type: 'string', - 'x-component': 'Input', - required: true, - } as ISchema, - }, - { - type: 'string', - name: 'clientId', - interface: 'input', - uiSchema: { - title: '{{t("Client id")}}', - type: 'string', - 'x-component': 'Input', - required: true, - } as ISchema, - }, - { - type: 'string', - name: 'clientSecret', - interface: 'input', - uiSchema: { - title: '{{t("Client secret")}}', - type: 'string', - 'x-component': 'Input', - required: true, - } as ISchema, - }, - { - type: 'string', - name: 'issuer', - interface: 'input', - uiSchema: { - title: '{{t("Issuer")}}', - type: 'string', - 'x-component': 'Input', - required: true, - } as ISchema, - }, - { - type: 'string', - name: 'openidConfiguration', - interface: 'input', - uiSchema: { - title: '{{t("Openid configuration")}}', - type: 'string', - 'x-component': 'Input', - required: true, - } as ISchema, - }, - { - type: 'string', - name: 'authorizeUrl', - interface: 'input', - uiSchema: { - title: '{{t("Authorization endpoint")}}', - type: 'string', - 'x-component': 'Input', - required: true, - } as ISchema, - }, - { - type: 'string', - name: 'tokenUrl', - interface: 'input', - uiSchema: { - title: '{{t("Access token endpoint")}}', - type: 'string', - 'x-component': 'Input', - required: true, - } as ISchema, - }, - { - type: 'string', - name: 'jwksUrl', - interface: 'input', - uiSchema: { - title: '{{t("JWKS endpoint")}}', - type: 'string', - 'x-component': 'Input', - required: true, - } as ISchema, - }, - { - type: 'string', - name: 'userinfoUrl', - interface: 'input', - uiSchema: { - title: '{{t("Userinfo endpoint")}}', - type: 'string', - 'x-component': 'Input', - required: true, - } as ISchema, - }, - { - type: 'string', - name: 'logoutUrl', - interface: 'input', - uiSchema: { - title: '{{t("Logout endpoint")}}', - type: 'string', - 'x-component': 'Input', - required: true, - } as ISchema, - }, - { - type: 'string', - name: 'idTokenSignAlg', - interface: 'select', - uiSchema: { - title: '{{ t("Id token sign alg") }}', - type: 'string', - 'x-component': 'Select', - 'x-component-props': { - showSearch: false, - filterSort: false, - options: [ - { - label: IDTOKEN_SIGN_ALG.RS256, - value: IDTOKEN_SIGN_ALG.RS256, - }, - { - label: IDTOKEN_SIGN_ALG.HS256, - value: IDTOKEN_SIGN_ALG.HS256, - }, - ], - }, - required: true, - } as ISchema, - }, - { - type: 'string', - name: 'redirectUrl', - interface: 'input', - uiSchema: { - title: '{{t("Redirect url")}}', - type: 'string', - 'x-component': 'RedirectURLInput', - } as ISchema, - }, - { - type: 'boolean', - name: 'enabled', - interface: 'boolean', - uiSchema: { - title: '{{t("Enable")}}', - type: 'boolean', - 'x-component': 'Checkbox', - } as ISchema, - }, - ], -}; - -export const formProperties = { - title: { - 'x-component': 'CollectionField', - 'x-decorator': 'FormItem', - description: '{{t("Sign in button name, which will be displayed on the sign in page")}}', - }, - clientId: { - 'x-component': 'CollectionField', - 'x-decorator': 'FormItem', - }, - clientSecret: { - 'x-component': 'CollectionField', - 'x-decorator': 'FormItem', - }, - issuer: { - 'x-component': 'CollectionField', - 'x-decorator': 'FormItem', - }, - openidConfiguration: { - 'x-component': 'CollectionField', - 'x-decorator': 'FormItem', - }, - authorizeUrl: { - 'x-component': 'CollectionField', - 'x-decorator': 'FormItem', - }, - tokenUrl: { - 'x-component': 'CollectionField', - 'x-decorator': 'FormItem', - }, - jwksUrl: { - 'x-component': 'CollectionField', - 'x-decorator': 'FormItem', - }, - userinfoUrl: { - 'x-component': 'CollectionField', - 'x-decorator': 'FormItem', - }, - logoutUrl: { - 'x-component': 'CollectionField', - 'x-decorator': 'FormItem', - }, - idTokenSignAlg: { - 'x-component': 'CollectionField', - 'x-decorator': 'FormItem', - }, - redirectUrl: { - 'x-component': 'CollectionField', - 'x-decorator': 'FormItem', - }, - enabled: { - 'x-component': 'CollectionField', - 'x-decorator': 'FormItem', - title: '', - 'x-content': '{{t("Enable")}}', - }, -}; - -export const oidcSchema: ISchema = { - type: 'object', - properties: { - block1: { - type: 'void', - 'x-decorator': 'ResourceActionProvider', - 'x-decorator-props': { - collection, - resourceName: 'oidcProviders', - request: { - resource: 'oidcProviders', - action: 'list', - params: { - pageSize: 50, - sort: ['id'], - appends: [], - }, - }, - }, - 'x-component': 'CollectionProvider', - 'x-component-props': { - collection, - }, - properties: { - actions: { - type: 'void', - 'x-component': 'ActionBar', - 'x-component-props': { - style: { - marginBottom: 16, - }, - }, - properties: { - delete: { - type: 'void', - title: '{{ t("Delete") }}', - 'x-component': 'Action', - 'x-component-props': { - useAction: '{{ cm.useBulkDestroyAction }}', - confirm: { - title: "{{t('Delete provider')}}", - content: "{{t('Are you sure you want to delete it?')}}", - }, - }, - }, - create: { - type: 'void', - title: '{{t("Add provider")}}', - 'x-component': 'Action', - 'x-component-props': { - type: 'primary', - }, - properties: { - drawer: { - type: 'void', - 'x-component': 'Action.Drawer', - 'x-decorator': 'Form', - 'x-decorator-props': { - useValues(options) { - const ctx = useActionContext(); - // 初始化数据 - return useRequest( - () => - Promise.resolve({ - data: { - enable: true, - idTokenSignAlg: IDTOKEN_SIGN_ALG.RS256, - }, - }), - { ...options, refreshDeps: [ctx.visible] }, - ); - }, - }, - title: '{{t("Add provider")}}', - properties: { - ...formProperties, - footer: { - type: 'void', - 'x-component': 'Action.Drawer.Footer', - properties: { - cancel: { - title: '{{t("Cancel")}}', - 'x-component': 'Action', - 'x-component-props': { - useAction: '{{ cm.useCancelAction }}', - }, - }, - submit: { - title: '{{t("Submit")}}', - 'x-component': 'Action', - 'x-component-props': { - type: 'primary', - useAction: '{{ cm.useCreateAction }}', - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - table: { - type: 'void', - 'x-uid': 'input', - 'x-component': 'Table.Void', - 'x-component-props': { - rowKey: 'id', - rowSelection: { - type: 'checkbox', - }, - useDataSource: '{{ cm.useDataSourceFromRAC }}', - }, - properties: { - column1: { - type: 'void', - 'x-decorator': 'Table.Column.Decorator', - 'x-component': 'Table.Column', - properties: { - title: { - type: 'string', - 'x-component': 'CollectionField', - 'x-read-pretty': true, - }, - }, - }, - column2: { - type: 'void', - 'x-decorator': 'Table.Column.Decorator', - 'x-component': 'Table.Column', - properties: { - redirectUrl: { - type: 'string', - 'x-component': 'CollectionField', - 'x-read-pretty': true, - }, - }, - }, - column3: { - type: 'void', - 'x-decorator': 'Table.Column.Decorator', - 'x-component': 'Table.Column', - properties: { - enabled: { - type: 'boolean', - 'x-component': 'CollectionField', - 'x-read-pretty': true, - }, - }, - }, - column4: { - type: 'void', - title: '{{t("Actions")}}', - 'x-component': 'Table.Column', - properties: { - actions: { - type: 'void', - 'x-component': 'Space', - 'x-component-props': { - split: '|', - }, - properties: { - update: { - type: 'void', - title: '{{t("Edit")}}', - 'x-component': 'Action.Link', - 'x-component-props': { - type: 'primary', - }, - properties: { - drawer: { - type: 'void', - 'x-component': 'Action.Drawer', - 'x-decorator': 'Form', - 'x-decorator-props': { - useValues: '{{ cm.useValuesFromRecord }}', - }, - title: '{{t("Edit provider")}}', - properties: { - ...formProperties, - footer: { - type: 'void', - 'x-component': 'Action.Drawer.Footer', - properties: { - cancel: { - title: '{{t("Cancel")}}', - 'x-component': 'Action', - 'x-component-props': { - useAction: '{{ cm.useCancelAction }}', - }, - }, - submit: { - title: '{{t("Submit")}}', - 'x-component': 'Action', - 'x-component-props': { - type: 'primary', - useAction: '{{ cm.useUpdateAction }}', - }, - }, - }, - }, - }, - }, - }, - }, - delete: { - type: 'void', - title: '{{ t("Delete") }}', - 'x-component': 'Action.Link', - 'x-component-props': { - confirm: { - title: "{{t('Delete role')}}", - content: "{{t('Are you sure you want to delete it?')}}", - }, - useAction: '{{cm.useDestroyAction}}', - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, -}; diff --git a/packages/plugins/oidc/src/constants.ts b/packages/plugins/oidc/src/constants.ts new file mode 100644 index 000000000..06c5d70b1 --- /dev/null +++ b/packages/plugins/oidc/src/constants.ts @@ -0,0 +1,3 @@ +export const authType = 'OIDC'; +export const cookieName = 'nocobase_oidc'; +export const namespace = require('../package.json').name; diff --git a/packages/plugins/oidc/src/index.ts b/packages/plugins/oidc/src/index.ts index 179594196..7ddad5814 100644 --- a/packages/plugins/oidc/src/index.ts +++ b/packages/plugins/oidc/src/index.ts @@ -1,3 +1 @@ export { default } from './server'; - -export const namespace = require('../package.json').name; diff --git a/packages/plugins/oidc/src/server/__tests__/oidc.test.ts b/packages/plugins/oidc/src/server/__tests__/oidc.test.ts new file mode 100644 index 000000000..03025ff31 --- /dev/null +++ b/packages/plugins/oidc/src/server/__tests__/oidc.test.ts @@ -0,0 +1,115 @@ +import { Database } from '@nocobase/database'; +import { MockServer, mockServer } from '@nocobase/test'; +import OIDCPlugin from '@nocobase/plugin-oidc'; +import { authType } from '../../constants'; +import { OIDCAuth } from '../oidc-auth'; + +describe('oidc', () => { + let app: MockServer; + let db: Database; + let agent; + + beforeAll(async () => { + app = mockServer({ + plugins: ['users', 'auth'], + }); + app.plugin(OIDCPlugin); + await app.loadAndInstall({ clean: true }); + db = app.db; + agent = app.agent(); + + const authenticatorRepo = db.getRepository('authenticators'); + await authenticatorRepo.create({ + values: { + name: 'oidc-auth', + authType: authType, + enabled: 1, + options: { + oidc: { + issuer: '', + clientId: '', + clientSecret: '', + }, + }, + }, + }); + }); + + afterAll(async () => { + await db.close(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should get auth url', async () => { + agent = app.agent(); + jest.spyOn(OIDCAuth.prototype, 'createOIDCClient').mockResolvedValue({ + authorizationUrl: ({ state }) => state, + } 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); + expect(search.get('token')).toBeDefined(); + expect(search.get('name')).toBe('oidc-auth'); + expect(res.headers['set-cookie']).toBeDefined(); + const token = res.headers['set-cookie'][0].split(';')[0].split('=')[1]; + expect(token).toBe(search.get('token')); + }); + + it('should sign in', async () => { + agent = app.agent(); + jest.spyOn(OIDCAuth.prototype, 'createOIDCClient').mockResolvedValue({ + callback: (uri, { code }) => ({ + access_token: 'access_token', + }), + userinfo: () => ({ + sub: 'user1', + }), + } as any); + + const res = await agent + .set('X-Authenticator', 'oidc-auth') + .set('Cookie', ['nocobase_oidc=token']) + .resource('auth') + .signIn() + .send({ + code: '', + state: 'token=token&name=oidc-auth', + }); + + expect(res.body.data.user).toBeDefined(); + expect(res.body.data.user.nickname).toBe('user1'); + }); +}); + +it('field mapping', () => { + const auth = new OIDCAuth({ + authenticator: null, + ctx: { + db: { + getCollection: () => ({}), + } as any, + } as any, + options: { + oidc: { + fieldMap: [ + { + source: 'username', + target: 'nickname', + }, + ], + }, + }, + }); + const userInfo = auth.mapField({ + sub: 1, + username: 'user1', + }); + expect(userInfo).toEqual({ + sub: 1, + username: 'user1', + nickname: 'user1', + }); +}); diff --git a/packages/plugins/oidc/src/server/actions/getAuthUrl.ts b/packages/plugins/oidc/src/server/actions/getAuthUrl.ts index 328881523..616a0e9c9 100644 --- a/packages/plugins/oidc/src/server/actions/getAuthUrl.ts +++ b/packages/plugins/oidc/src/server/actions/getAuthUrl.ts @@ -1,23 +1,22 @@ -import { Context } from '@nocobase/actions'; -import { createOIDCClient } from '../shared/createOIDCClient'; -import { OIDCProvider } from '../shared/types'; +import { Context, Next } from '@nocobase/actions'; +import { OIDCAuth } from '../oidc-auth'; +import { nanoid } from 'nanoid'; +import { cookieName } from '../../constants'; -export const getAuthUrl = async (ctx: Context, next) => { - const { - params: { values }, - } = ctx.action; - const providerRepo = ctx.db.getRepository('oidcProviders'); - const record = await providerRepo.findOne({ - filter: { - clientId: values.clientId, - }, +export const getAuthUrl = async (ctx: Context, next: Next) => { + const auth = ctx.auth as OIDCAuth; + const client = await auth.createOIDCClient(); + const { scope } = auth.getOptions(); + const token = nanoid(15); + ctx.cookies.set(cookieName, token, { + httpOnly: true, + domain: ctx.hostname, }); - const provider: OIDCProvider = record.toJSON(); - const client = await createOIDCClient(provider); - ctx.body = client.authorizationUrl({ - nonce: ctx.OIDC_NONCE, - scope: 'openid profile', + response_type: 'code', + scope: scope || 'openid email profile', + redirect_uri: auth.getRedirectUri(), + state: `token=${token}&name=${ctx.headers['x-authenticator']}`, }); return next(); diff --git a/packages/plugins/oidc/src/server/actions/getEnabledProviders.ts b/packages/plugins/oidc/src/server/actions/getEnabledProviders.ts deleted file mode 100644 index 02d97b08f..000000000 --- a/packages/plugins/oidc/src/server/actions/getEnabledProviders.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Context } from '@nocobase/actions'; - -export const getEnabledProviders = async (ctx: Context, next) => { - const repository = ctx.db.getRepository('oidcProviders'); - ctx.body = await repository.find({ - filter: { - enabled: true, - }, - fields: ['title', 'clientId'], - }); - return next(); -}; diff --git a/packages/plugins/oidc/src/server/authenticators/oidc.ts b/packages/plugins/oidc/src/server/authenticators/oidc.ts deleted file mode 100644 index 1924aed96..000000000 --- a/packages/plugins/oidc/src/server/authenticators/oidc.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Context } from '@nocobase/actions'; -import { createOIDCClient } from '../shared/createOIDCClient'; -import { OIDCProvider } from '../shared/types'; - -export const oidc = async (ctx: Context, next) => { - const { - params: { values }, - } = ctx.action; - - const providerRepo = ctx.db.getRepository('oidcProviders'); - const record = await providerRepo.findOne({ - filter: { - clientId: values.clientId, - }, - }); - const provider: OIDCProvider = record.toJSON(); - const client = await createOIDCClient(provider); - - const tokens = await client.callback( - provider.redirectUrl, - { - code: values.code, - }, - { - nonce: ctx.OIDC_NONCE, - }, - ); - - const userinfo = await client.userinfo(tokens.access_token); - const usersRepo = ctx.db.getRepository('users'); - - const name = userinfo.preferred_username || userinfo.nickname || userinfo.name; - - let user = await usersRepo.findOne({ - filter: { - nickname: name, - email: userinfo.email ?? null, - }, - }); - - if (!user) { - user = await usersRepo.create({ - values: { - nickname: name, - }, - }); - } - - ctx.state.currentUser = user; - - return next(); -}; diff --git a/packages/plugins/oidc/src/server/collections/oidcProviders.ts b/packages/plugins/oidc/src/server/collections/oidcProviders.ts deleted file mode 100644 index 5fcaa5f10..000000000 --- a/packages/plugins/oidc/src/server/collections/oidcProviders.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { CollectionOptions } from '@nocobase/database'; - -export default { - namespace: 'oidc.oidcProviders', - duplicator: 'optional', - name: 'oidcProviders', - title: '{{t("OIDC Providers")}}', - fields: [ - { - title: 'Provider 名称', - comment: 'Provider 名称', - type: 'string', - name: 'title', - }, - { - comment: '客户端id', - type: 'string', - name: 'clientId', - unique: true, - }, - { - comment: '客户端密钥', - type: 'string', - name: 'clientSecret', - }, - { - title: 'issuer', - comment: '用于标识 token 发放来源的字段', - type: 'string', - name: 'issuer', - }, - { - comment: '用于获取当前 Provider 支持的各端点信息和支持的模式、参数信息,可公开访问', - type: 'string', - name: 'openidConfiguration', - }, - { - comment: '单点登录的地址 ', - type: 'string', - name: 'authorizeUrl', - }, - { - comment: '获取取 token 的接口地址', - type: 'string', - name: 'tokenUrl', - }, - { - comment: '令牌吊销 token 的接口', - type: 'string', - name: 'revokeUrl', - }, - { - comment: '验证 id_token 的接口', - type: 'string', - name: 'jwksUrl', - }, - { - comment: '获取用户信息的接口', - type: 'string', - name: 'userinfoUrl', - }, - { - comment: '是否启用', - type: 'boolean', - name: 'enabled', - }, - { - comment: '重定向回调地址', - type: 'string', - name: 'redirectUrl', - }, - { - comment: '登出端点', - type: 'string', - name: 'logoutUrl', - }, - { - comment: 'id token 加密方式', - type: 'string', - name: 'idTokenSignAlg', - }, - { - comment: '获取用户信息的接口', - type: 'json', - name: 'attrsMap', - }, - ], -} as CollectionOptions; diff --git a/packages/plugins/oidc/src/server/oidc-auth.ts b/packages/plugins/oidc/src/server/oidc-auth.ts new file mode 100644 index 000000000..fd53245a3 --- /dev/null +++ b/packages/plugins/oidc/src/server/oidc-auth.ts @@ -0,0 +1,91 @@ +import { AuthConfig, BaseAuth } from '@nocobase/auth'; +import { Issuer } from 'openid-client'; +import { cookieName } from '../constants'; + +export class OIDCAuth extends BaseAuth { + constructor(config: AuthConfig) { + const { ctx } = config; + super({ + ...config, + userCollection: ctx.db.getCollection('users'), + }); + } + + getRedirectUri() { + const ctx = this.ctx; + const { http } = this.getOptions(); + const protocol = http ? 'http' : 'https'; + return `${protocol}://${ctx.host}/api/oidc:redirect`; + } + + getOptions() { + return this.options?.oidc || {}; + } + + mapField(userInfo: { [source: string]: any }) { + const { fieldMap } = this.getOptions(); + if (!fieldMap) { + return userInfo; + } + fieldMap.forEach((item: { source: string; target: string }) => { + const { source, target } = item; + if (userInfo[source]) { + userInfo[target] = userInfo[source]; + } + }); + return userInfo; + } + + async createOIDCClient() { + const { issuer, clientId, clientSecret } = this.getOptions(); + const oidc = await Issuer.discover(issuer); + return new oidc.Client({ + client_id: clientId, + client_secret: clientSecret, + }); + } + + async validate() { + const ctx = this.ctx; + const { + params: { values }, + } = ctx.action; + const token = ctx.cookies.get(cookieName); + const search = new URLSearchParams(values.state); + if (search.get('token') !== token) { + ctx.app.logger.warn('odic-auth: state mismatch'); + return null; + } + const client = await this.createOIDCClient(); + const tokens = await client.callback(this.getRedirectUri(), { + code: values.code, + }); + const userInfo: { [key: string]: any } = await client.userinfo(tokens.access_token); + const mappedUserInfo = this.mapField(userInfo); + const { nickname, name, sub, email, phone } = mappedUserInfo; + const username = nickname || name || sub; + // Compatible processing + // When email is provided, use email to find user + // If found, associate the user with the current authenticator + if (email) { + const userRepo = this.userCollection.repository; + const user = await userRepo.findOne({ + filter: { email }, + }); + if (user) { + await this.authenticator.addUser(user, { + through: { + uuid: sub, + }, + }); + return user; + } + } + + return await this.authenticator.findOrCreateUser(sub, { + nickname: username, + email: email ?? null, + phone: phone ?? null, + }); + } +} diff --git a/packages/plugins/oidc/src/server/plugin.ts b/packages/plugins/oidc/src/server/plugin.ts index 21384b5c4..990244731 100644 --- a/packages/plugins/oidc/src/server/plugin.ts +++ b/packages/plugins/oidc/src/server/plugin.ts @@ -1,27 +1,18 @@ import { InstallOptions, Plugin } from '@nocobase/server'; -import { generators } from 'openid-client'; -import { resolve } from 'path'; import { getAuthUrl } from './actions/getAuthUrl'; -import { getEnabledProviders } from './actions/getEnabledProviders'; import { redirect } from './actions/redirect'; -import { oidc } from './authenticators/oidc'; +import { authType } from '../constants'; +import { OIDCAuth } from './oidc-auth'; export class OidcPlugin extends Plugin { - #OIDC_NONCE = null; - afterAdd() {} beforeLoad() {} async load() { - // 导入 collection - await this.importCollections(resolve(__dirname, './collections')); - - // 获取 User 插件 - const userPlugin = this.app.getPlugin('users'); - - // 注册 OIDC 验证器 - userPlugin.authenticators.register('oidc', oidc); + this.app.authManager.registerTypes(authType, { + auth: OIDCAuth, + }); // 注册接口 this.app.resource({ @@ -29,27 +20,10 @@ export class OidcPlugin extends Plugin { actions: { getAuthUrl, redirect, - getEnabledProviders, }, }); - // 注册中间件,处理 nonce 值 - this.app.use(async (ctx, next) => { - if (ctx.url.startsWith('/api/users:signin?authenticator=oidc')) { - ctx.OIDC_NONCE = this.#OIDC_NONCE; - } - if (ctx.url.startsWith('/api/oidc:getAuthUrl')) { - ctx.OIDC_NONCE = this.#OIDC_NONCE = generators.nonce(); - } - await next(); - }); - this.app.acl.allow('oidc', '*', 'public'); - - this.app.acl.registerSnippet({ - name: `pm.${this.name}.providers`, - actions: ['oidcProviders:*'], - }); } async install(options?: InstallOptions) {} diff --git a/packages/plugins/oidc/src/server/shared/createOIDCClient.ts b/packages/plugins/oidc/src/server/shared/createOIDCClient.ts deleted file mode 100644 index 1de2676cd..000000000 --- a/packages/plugins/oidc/src/server/shared/createOIDCClient.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Issuer } from 'openid-client'; -import { IDTOKEN_SIGN_ALG, OIDCProvider } from './types'; - -export const createOIDCClient = async (provider: OIDCProvider) => { - const issuer = await Issuer.discover(provider.issuer); - return new issuer.Client({ - client_id: provider.clientId, - client_secret: provider.clientSecret, - response_types: ['code'], - id_token_signed_response_alg: provider.idTokenSignAlg, - }); -}; diff --git a/packages/plugins/oidc/src/server/shared/types.ts b/packages/plugins/oidc/src/server/shared/types.ts deleted file mode 100644 index ffc340fbc..000000000 --- a/packages/plugins/oidc/src/server/shared/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -export enum IDTOKEN_SIGN_ALG { - HS256 = 'HS256', - RS256 = 'RS256', -} - -export interface OIDCProvider { - title?: string; - clientId?: string; - clientSecret?: string; - issuer?: string; - openidConfiguration?: string; - authorizeUrl?: string; - tokenUrl?: string; - revodeUrl?: string; - jwksUrl?: string; - userinfoUrl?: string; - enable?: string; - redirectUrl?: string; - logoutUrl?: string; - idTokenSignAlg?: string; - attrsMap?: Record; -} diff --git a/packages/plugins/saml/README.zh-CN.md b/packages/plugins/saml/README.zh-CN.md index 8121817e3..1fa35a61e 100644 --- a/packages/plugins/saml/README.zh-CN.md +++ b/packages/plugins/saml/README.zh-CN.md @@ -1,11 +1,55 @@ # SAML -[English](./README.md) | 中文 +提供SAML2.0 SSO登录功能。 -## 安装激活 +## 依赖 -```bash -yarn pm enable saml -``` +- `@nocobase/auth` 认证插件,提供认证相关功能,表、模型、函数复用等。 ## 使用方法 + +> 以Google WorkSpace IdP为例 + +### 将Google设置为IdP + +[Google管理控制台](https://admin.google.com/) - 应用 - Web应用和移动应用 + + + + +进行应用设置之后,复制**SSO网址**、**实体ID**和**证书**。 + + + +### 在Nocobase上新增认证器 + +插件设置 - 认证 - 新增 - SAML + + + +将刚才复制的信息依次进行填写 +- SSO URL: SSO网址 +- Public Certificate: 证书 +- idP Issuer: 实体id +- http: 如果是本地http测试可以勾选 + +之后复制`Usage`中的`SP Issuer/EntityID`和`ACS URL`. + +### 在Google上填写SP信息 + +回到Google控制台,在**服务提供商详细信息**页面,输入刚才复制的ACS网址和实体ID,并勾选**已签署响应**。 + +在**属性映射**位置,添加映射,映射对应属性,Nocobase可供映射的字段有: + +- email(必填) +- phone (仅对scope支持phone的平台生效,如阿里云) +- nickname +- username +- firstName +- lastName + +用户名使用规则优先级: `nickname` > `username` > `firstName lastName` > `nameID` + +`nameID`为SAML协议携带,无需映射,将作为用户唯一标识保存。 + +在有email或nameID为email的情况下,登录时将尝试匹配已有用户,否则创建新用户。 \ No newline at end of file diff --git a/packages/plugins/saml/package.json b/packages/plugins/saml/package.json index 82fd5ac89..2c7309461 100644 --- a/packages/plugins/saml/package.json +++ b/packages/plugins/saml/package.json @@ -1,8 +1,8 @@ { "name": "@nocobase/plugin-saml", - "displayName": "saml", - "displayName.zh-CN": "saml", - "description": "provide SAML authentication and authorization functionality for applications or authentication systems.", + "displayName": "SAML", + "displayName.zh-CN": "SAML", + "description": "Provides SAML authentication and authorization functionality for applications or authentication systems.", "description.zh-CN": "为应用程序或身份验证系统提供SAML认证和授权功能。", "version": "0.9.4-alpha.2", "license": "AGPL-3.0", @@ -10,9 +10,10 @@ "types": "./lib/index.d.ts", "devDependencies": { "@nocobase/server": "0.9.4-alpha.2", - "@nocobase/test": "0.9.4-alpha.2" + "@nocobase/test": "0.9.4-alpha.2", + "@nocobase/auth": "0.9.4-alpha.2" }, "dependencies": { "@node-saml/node-saml": "^4.0.2" } -} +} \ No newline at end of file diff --git a/packages/plugins/saml/src/client/Options.tsx b/packages/plugins/saml/src/client/Options.tsx new file mode 100644 index 000000000..4d796c002 --- /dev/null +++ b/packages/plugins/saml/src/client/Options.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { SchemaComponent } from '@nocobase/client'; +import { Card, message } from 'antd'; +import { CopyOutlined } from '@ant-design/icons'; +import { observer, useForm } from '@formily/react'; +import { useRecord, FormItem, Input } from '@nocobase/client'; +import { useSamlTranslation } from './locale'; + +const schema = { + type: 'object', + properties: { + saml: { + type: 'object', + properties: { + ssoUrl: { + title: '{{t("SSO URL")}}', + 'x-component': 'Input', + 'x-decorator': 'FormItem', + required: true, + }, + certificate: { + title: '{{t("Public Certificate")}}', + 'x-component': 'Input.TextArea', + 'x-decorator': 'FormItem', + required: true, + }, + idpIssuer: { + title: 'idP Issuer', + 'x-component': 'Input', + 'x-decorator': 'FormItem', + }, + http: { + title: '{{t("http")}}', + 'x-component': 'Checkbox', + 'x-decorator': 'FormItem', + }, + usage: { + type: 'void', + 'x-component': 'Usage', + }, + }, + }, + }, +}; + +const Usage = observer(() => { + const form = useForm(); + const record = useRecord(); + const { t } = useSamlTranslation(); + + const name = form.values.name ?? record.name; + const { protocol, host } = window.location; + const url = `${protocol}//${host}/api/saml:redirect?authenticator=${name}`; + + const copy = (text: string) => { + navigator.clipboard.writeText(text); + message.success(t('Copied')); + }; + + return ( + + + copy(name)} />} /> + + + copy(url)} />} /> + + + ); +}); + +export const Options = () => { + const { t } = useSamlTranslation(); + return ; +}; diff --git a/packages/plugins/saml/src/client/RedirectURLInput.tsx b/packages/plugins/saml/src/client/RedirectURLInput.tsx deleted file mode 100644 index 976077f11..000000000 --- a/packages/plugins/saml/src/client/RedirectURLInput.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import { FormLayout } from '@formily/antd'; -import { Field } from '@formily/core'; -import { observer, useField, useForm } from '@formily/react'; -import { useEffect } from 'react'; -import { Input, useRecord } from '@nocobase/client'; - -export const RedirectURLInput = observer( - () => { - const form = useForm(); - const field = useField(); - const record = useRecord(); - - const clientId = form.values.clientId ?? record.clientId; - - useEffect(() => { - const { protocol, host } = window.location; - field.setValue(`${protocol}//${host}/api/saml:redirect?clientId=${clientId}`); - }, [clientId]); - - return ( -
- - - -
- ); - }, - { displayName: 'RedirectURLInput' }, -); diff --git a/packages/plugins/saml/src/client/SAMLButton.tsx b/packages/plugins/saml/src/client/SAMLButton.tsx new file mode 100644 index 000000000..0a78c4742 --- /dev/null +++ b/packages/plugins/saml/src/client/SAMLButton.tsx @@ -0,0 +1,78 @@ +import { LoginOutlined } from '@ant-design/icons'; +import { css } from '@emotion/css'; +import { Authenticator, useAPIClient, useRedirect } from '@nocobase/client'; +import { Button, Space } from 'antd'; +import React, { useEffect, useState, useCallback } from 'react'; + +export const SAMLButton = (props: { authenticator: Authenticator }) => { + const [windowHandler, setWindowHandler] = useState(); + const api = useAPIClient(); + const redirect = useRedirect(); + + /** + * 打开登录弹出框 + */ + const handleOpen = async (name: string) => { + const response = await api.request({ + method: 'post', + url: 'saml:getAuthUrl', + headers: { + 'X-Authenticator': name, + }, + }); + + const authUrl = response?.data?.data; + const { width, height } = screen; + + const win = window.open( + authUrl, + '_blank', + `width=800,height=600,left=${(width - 800) / 2},top=${ + (height - 600) / 2 + },toolbar=no,menubar=no,location=no,status=no`, + ); + + setWindowHandler(win); + }; + + const handleSAMLLogin = useCallback( + async (event: MessageEvent) => { + try { + await api.auth.signIn(event.data, event.data?.authenticator); + redirect(); + } catch (err) { + console.error(err); + } finally { + windowHandler.close(); + setWindowHandler(undefined); + } + }, + [api, redirect, windowHandler], + ); + + /** + * 监听弹出窗口的消息 + */ + useEffect(() => { + if (!windowHandler) return; + + window.addEventListener('message', handleSAMLLogin); + return () => { + window.removeEventListener('message', handleSAMLLogin); + }; + }, [windowHandler, handleSAMLLogin]); + + const authenticator = props.authenticator; + return ( + + + + ); +}; diff --git a/packages/plugins/saml/src/client/SAMLList.tsx b/packages/plugins/saml/src/client/SAMLList.tsx deleted file mode 100644 index 64d4d07d2..000000000 --- a/packages/plugins/saml/src/client/SAMLList.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { LoginOutlined } from '@ant-design/icons'; -import { css } from '@emotion/css'; -import { useAPIClient, useRedirect, useRequest } from '@nocobase/client'; -import { Button, Space } from 'antd'; -import React, { useEffect, useState } from 'react'; - -export interface SAMLProvider { - title: string; - clientId: string; - loginUrl: string; -} - -export const SAMLList = () => { - const [windowHandler, setWindowHandler] = useState(); - const api = useAPIClient(); - const redirect = useRedirect(); - - const { data, loading } = useRequest({ - resource: 'saml', - action: 'getEnabledProviders', - }); - - /** - * 打开登录弹出框 - */ - const handleOpen = async (item: SAMLProvider) => { - const response = await api.request({ - method: 'post', - url: 'saml:getAuthUrl', - data: { - clientId: item.clientId, - }, - }); - - const authUrl = response?.data?.data; - const { width, height } = screen; - - const win = window.open( - authUrl, - '_blank', - `width=800,height=600,left=${(width - 800) / 2},top=${ - (height - 600) / 2 - },toolbar=no,menubar=no,location=no,status=no`, - ); - - setWindowHandler(win); - }; - - /** - * 从弹出窗口,发消息回来进行登录 - */ - const handleOIDCLogin = async (event: MessageEvent) => { - await api.auth.signIn(event.data, 'saml'); - windowHandler.close(); - setWindowHandler(undefined); - redirect(); - }; - - /** - * 监听弹出窗口的消息 - */ - useEffect(() => { - if (!windowHandler) return; - window.addEventListener('message', handleOIDCLogin); - return () => { - window.removeEventListener('message', handleOIDCLogin); - }; - }, [windowHandler]); - - return ( - - {data?.data?.map?.((item) => ( - - ))} - - ); -}; diff --git a/packages/plugins/saml/src/client/SAMLPanel.tsx b/packages/plugins/saml/src/client/SAMLPanel.tsx deleted file mode 100644 index fdd0f80e4..000000000 --- a/packages/plugins/saml/src/client/SAMLPanel.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import { uid } from '@formily/shared'; -import { SchemaComponent, useRecord } from '@nocobase/client'; -import { Card } from 'antd'; -import { samlSchema } from './schemas/saml'; -import { RedirectURLInput } from './RedirectURLInput'; -import { useSamlTranslation } from './locale'; - -const schema = { - type: 'object', - properties: { - [uid()]: samlSchema, - }, -}; - -export const SAMLPanel = () => { - const { t } = useSamlTranslation(); - - return ( - - - - ); -}; diff --git a/packages/plugins/saml/src/client/index.tsx b/packages/plugins/saml/src/client/index.tsx index f90d39f14..b952fbfc8 100644 --- a/packages/plugins/saml/src/client/index.tsx +++ b/packages/plugins/saml/src/client/index.tsx @@ -1,42 +1,15 @@ -import { PluginManagerContext, SettingsCenterProvider, SigninPageExtensionProvider } from '@nocobase/client'; +import { AuthenticatorsContext, OptionsComponentProvider, SigninPageExtensionProvider } from '@nocobase/client'; import React, { useContext } from 'react'; -import { useSamlTranslation } from './locale'; -import { SAMLList } from './SAMLList'; -import { SAMLPanel } from './SAMLPanel'; +import { SAMLButton } from './SAMLButton'; +import { Options } from './Options'; +import { authType } from '../constants'; export default function (props) { - const ctx = useContext(PluginManagerContext); - const { t } = useSamlTranslation(); - return ( - - - - {props.children} - - + + + {props.children} + ); } diff --git a/packages/plugins/saml/src/client/locale/zh-CN.ts b/packages/plugins/saml/src/client/locale/zh-CN.ts index d9247dc9d..332ae2406 100644 --- a/packages/plugins/saml/src/client/locale/zh-CN.ts +++ b/packages/plugins/saml/src/client/locale/zh-CN.ts @@ -20,4 +20,6 @@ export default { 'Delete provider': '删除', 'Are you sure you want to delete it?': '你确定要删除它吗?', 'Sign in button name, which will be displayed on the sign in page': '登录按钮名称,将在登录页中显示', + Copied: '已复制', + Usage: '使用', }; diff --git a/packages/plugins/saml/src/constants.ts b/packages/plugins/saml/src/constants.ts new file mode 100644 index 000000000..c261693dd --- /dev/null +++ b/packages/plugins/saml/src/constants.ts @@ -0,0 +1,2 @@ +export const authType = 'SAML'; +export const namespace = require('../package.json').name; diff --git a/packages/plugins/saml/src/index.ts b/packages/plugins/saml/src/index.ts index 179594196..7ddad5814 100644 --- a/packages/plugins/saml/src/index.ts +++ b/packages/plugins/saml/src/index.ts @@ -1,3 +1 @@ export { default } from './server'; - -export const namespace = require('../package.json').name; diff --git a/packages/plugins/saml/src/server/__tests__/saml.test.ts b/packages/plugins/saml/src/server/__tests__/saml.test.ts new file mode 100644 index 000000000..6f0c6e997 --- /dev/null +++ b/packages/plugins/saml/src/server/__tests__/saml.test.ts @@ -0,0 +1,115 @@ +import { Database } from '@nocobase/database'; +import { MockServer, mockServer } from '@nocobase/test'; +import SAMLPlugin from '@nocobase/plugin-saml'; +import { authType } from '../../constants'; +import { SAML } from '@node-saml/node-saml'; + +describe('saml', () => { + let app: MockServer; + let db: Database; + let agent; + + beforeAll(async () => { + app = mockServer({ + plugins: ['users', 'auth'], + }); + app.plugin(SAMLPlugin); + await app.loadAndInstall({ clean: true }); + db = app.db; + agent = app.agent(); + + const authenticatorRepo = db.getRepository('authenticators'); + await authenticatorRepo.create({ + values: { + name: 'saml-auth', + authType: authType, + enabled: 1, + options: { + saml: { + ssoUrl: 'http://localhost:3000/saml/sso', + certificate: `certificate`, + idpIssuer: 'idpIssuer', + }, + }, + }, + }); + }); + + afterAll(async () => { + await db.close(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should get auth url', async () => { + const res = await agent.set('X-Authenticator', 'saml-auth').resource('saml').getAuthUrl(); + expect(res.body.data).toBeDefined(); + }); + + it('should sign in', async () => { + jest.spyOn(SAML.prototype, 'validatePostResponseAsync').mockResolvedValue({ + profile: { + nameID: 'test@nocobase.com', + email: 'test@nocobase.com', + firstName: 'Test', + lastName: 'Nocobase', + issuer: 'issuer', + nameIDFormat: 'Email', + }, + loggedOut: false, + }); + + const res = await agent + .set('X-Authenticator', 'saml-auth') + .resource('auth') + .signIn() + .send({ + samlResponse: { + SAMLResponse: '', + }, + }); + + expect(res.body.data.user).toBeDefined(); + expect(res.body.data.user.nickname).toBe('Test Nocobase'); + }); + + it('should sign in via email', async () => { + jest.spyOn(SAML.prototype, 'validatePostResponseAsync').mockResolvedValue({ + profile: { + nameID: 'old@nocobase.com', + email: 'old@nocobase.com', + firstName: 'Old', + lastName: 'Nocobase', + issuer: 'issuer', + nameIDFormat: 'Email', + }, + loggedOut: false, + }); + + const email = 'old@nocobase.com'; + const userRepo = db.getRepository('users'); + const user = await userRepo.create({ + values: { + nickname: email, + email, + }, + }); + + const res = await agent + .set('X-Authenticator', 'saml-auth') + .resource('auth') + .signIn() + .send({ + samlResponse: { + SAMLResponse: '', + }, + }); + + expect(res.body.data.user).toBeDefined(); + expect(res.body.data.user.id).toBe(user.id); + expect(res.body.data.user.email).toBe('old@nocobase.com'); + expect(res.body.data.user.nickname).toBe('old@nocobase.com'); + }); +}); diff --git a/packages/plugins/saml/src/server/actions/getAuthUrl.ts b/packages/plugins/saml/src/server/actions/getAuthUrl.ts index f3b52b6af..136f475eb 100644 --- a/packages/plugins/saml/src/server/actions/getAuthUrl.ts +++ b/packages/plugins/saml/src/server/actions/getAuthUrl.ts @@ -1,20 +1,11 @@ -import { Context } from '@nocobase/actions'; -import { getSaml } from '../shared/getSaml'; -import { SAMLProvider } from '../shared/types'; +import { Context, Next } from '@nocobase/actions'; +import { SAML } from '@node-saml/node-saml'; +import { SAMLAuth } from '../saml-auth'; -export const getAuthUrl = async (ctx: Context, next) => { - const { - params: { values }, - } = ctx.action; - const providerRepo = ctx.db.getRepository('samlProviders'); - - const record: SAMLProvider = await providerRepo.findOne({ - filter: { - clientId: values.clientId, - }, - }); - - const saml = getSaml(record); +export const getAuthUrl = async (ctx: Context, next: Next) => { + const auth = ctx.auth as SAMLAuth; + const options = auth.getOptions(); + const saml = new SAML(options); ctx.body = await saml.getAuthorizeUrlAsync('', '', {}); diff --git a/packages/plugins/saml/src/server/actions/getEnabledProviders.ts b/packages/plugins/saml/src/server/actions/getEnabledProviders.ts deleted file mode 100644 index 708e21389..000000000 --- a/packages/plugins/saml/src/server/actions/getEnabledProviders.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Context } from '@nocobase/actions'; - -export const getEnabledProviders = async (ctx: Context, next) => { - const repository = ctx.db.getRepository('samlProviders'); - ctx.body = await repository.find({ - filter: { - enabled: true, - }, - fields: ['title', 'clientId'], - }); - return next(); -}; diff --git a/packages/plugins/saml/src/server/actions/metadata.ts b/packages/plugins/saml/src/server/actions/metadata.ts index a5bfec0e7..1b4f1662f 100644 --- a/packages/plugins/saml/src/server/actions/metadata.ts +++ b/packages/plugins/saml/src/server/actions/metadata.ts @@ -1,22 +1,14 @@ -import { Context } from '@nocobase/actions'; -import { getSaml } from '../shared/getSaml'; +import { Context, Next } from '@nocobase/actions'; +import { SAMLAuth } from '../saml-auth'; +import { SAML } from '@node-saml/node-saml'; -export const metadata = async (ctx: Context, next) => { - const { - params: { clientId }, - } = ctx.action; - const providerRepo = ctx.db.getRepository('samlProviders'); - - const record = await providerRepo.findOne({ - filter: { - clientId: clientId, - }, - }); - - const saml = getSaml(record); +export const metadata = async (ctx: Context, next: Next) => { + const auth = ctx.auth as SAMLAuth; + const options = auth.getOptions(); + const saml = new SAML(options); ctx.type = 'text/xml'; - ctx.body = saml.generateServiceProviderMetadata(record.certificate); + ctx.body = saml.generateServiceProviderMetadata(options.cert as string); ctx.withoutDataWrapping = true; return next(); diff --git a/packages/plugins/saml/src/server/actions/redirect.ts b/packages/plugins/saml/src/server/actions/redirect.ts index 81cb0f3a9..0e8c3eed3 100644 --- a/packages/plugins/saml/src/server/actions/redirect.ts +++ b/packages/plugins/saml/src/server/actions/redirect.ts @@ -14,7 +14,10 @@ export const redirect = async (ctx: Context, next) => { diff --git a/packages/plugins/saml/src/server/authenticators/saml.ts b/packages/plugins/saml/src/server/authenticators/saml.ts deleted file mode 100644 index d2de65fe8..000000000 --- a/packages/plugins/saml/src/server/authenticators/saml.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Context } from '@nocobase/actions'; -import { SAML, SamlConfig } from '@node-saml/node-saml'; -import { getSaml } from '../shared/getSaml'; -import { SAMLProvider } from '../shared/types'; - -export const saml = async (ctx: Context, next) => { - const { - params: { - values: { clientId, samlResponse }, - }, - } = ctx.action; - - const providerRepo = ctx.db.getRepository('samlProviders'); - const record: SAMLProvider = await providerRepo.findOne({ - filter: { - clientId: clientId, - }, - }); - - const saml = getSaml(record); - - const { profile } = await saml.validatePostResponseAsync(samlResponse); - - const usersRepo = ctx.db.getRepository('users'); - - const { nameID, nickname, username, email } = profile as Record; - - const name = nickname ?? username ?? nameID; - - let user = await usersRepo.findOne({ - filter: { - nickname: name, - email: email ?? null, - }, - }); - - if (!user) { - user = await usersRepo.create({ - values: { - nickname: name, - }, - }); - } - - ctx.state.currentUser = user; - - return next(); -}; diff --git a/packages/plugins/saml/src/server/collections/samlProviders.ts b/packages/plugins/saml/src/server/collections/samlProviders.ts deleted file mode 100644 index 86f4ac438..000000000 --- a/packages/plugins/saml/src/server/collections/samlProviders.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { CollectionOptions } from '@nocobase/database'; - -export default { - namespace: 'saml.samlProviders', - duplicator: 'optional', - name: 'samlProviders', - title: '{{t("SAML Providers")}}', - fields: [ - { - comment: '标题', - type: 'string', - name: 'title', - }, - { - comment: '客户端id', - type: 'string', - name: 'clientId', - }, - { - comment: '唯一标识(entityId/Issuer)', - type: 'string', - name: 'issuer', - }, - { - comment: '登录地址(ACS)', - type: 'string', - name: 'loginUrl', - }, - { - comment: '公钥', - type: 'text', - name: 'certificate', - }, - { - comment: '重定向地址', - type: 'text', - name: 'redirectUrl', - }, - { - comment: '启用', - type: 'boolean', - name: 'enabled', - }, - ], -} as CollectionOptions; diff --git a/packages/plugins/saml/src/server/plugin.ts b/packages/plugins/saml/src/server/plugin.ts index fa6860a57..678d596fa 100644 --- a/packages/plugins/saml/src/server/plugin.ts +++ b/packages/plugins/saml/src/server/plugin.ts @@ -1,10 +1,9 @@ import { InstallOptions, Plugin } from '@nocobase/server'; -import { resolve } from 'path'; import { getAuthUrl } from './actions/getAuthUrl'; -import { getEnabledProviders } from './actions/getEnabledProviders'; import { metadata } from './actions/metadata'; import { redirect } from './actions/redirect'; -import { saml } from './authenticators/saml'; +import { SAMLAuth } from './saml-auth'; +import { authType } from '../constants'; export class SAMLPlugin extends Plugin { afterAdd() {} @@ -12,14 +11,9 @@ export class SAMLPlugin extends Plugin { beforeLoad() {} async load() { - // 导入 collection - await this.importCollections(resolve(__dirname, './collections')); - - // 获取 User 插件 - const userPlugin = this.app.getPlugin('users'); - - // 注册 SAML 验证器 - userPlugin.authenticators.register('saml', saml); + this.app.authManager.registerTypes(authType, { + auth: SAMLAuth, + }); // 注册接口 this.app.resource({ @@ -28,17 +22,11 @@ export class SAMLPlugin extends Plugin { redirect, metadata, getAuthUrl, - getEnabledProviders, }, }); // 开放访问权限 this.app.acl.allow('saml', '*', 'public'); - - this.app.acl.registerSnippet({ - name: `pm.${this.name}.providers`, - actions: ['samlProviders:*'], - }); } async install(options?: InstallOptions) {} diff --git a/packages/plugins/saml/src/server/saml-auth.ts b/packages/plugins/saml/src/server/saml-auth.ts new file mode 100644 index 000000000..2866de8a6 --- /dev/null +++ b/packages/plugins/saml/src/server/saml-auth.ts @@ -0,0 +1,75 @@ +import { AuthConfig, BaseAuth } from '@nocobase/auth'; +import { SAML, SamlConfig } from '@node-saml/node-saml'; + +interface SAMLOptions { + ssoUrl?: string; + certificate?: string; + idpIssuer?: string; + http?: boolean; +} + +export class SAMLAuth extends BaseAuth { + constructor(config: AuthConfig) { + const { ctx } = config; + super({ + ...config, + userCollection: ctx.db.getCollection('users'), + }); + } + + getOptions() { + const ctx = this.ctx; + const { ssoUrl, certificate, idpIssuer, http }: SAMLOptions = this.options?.saml || {}; + const name = this.authenticator.get('name'); + const protocol = http ? 'http' : 'https'; + return { + callbackUrl: `${protocol}://${ctx.host}/api/saml:redirect?authenticator=${name}`, + entryPoint: ssoUrl, + issuer: name, + cert: certificate, + idpIssuer, + wantAssertionsSigned: false, + } as SamlConfig; + } + + async validate() { + const ctx = this.ctx; + const { + params: { + values: { samlResponse }, + }, + } = ctx.action; + const saml = new SAML(this.getOptions()); + + const { profile } = await saml.validatePostResponseAsync(samlResponse); + + const { nameID, nickname, username, email, firstName, lastName, phone } = profile as Record; + + const fullName = firstName && lastName && `${firstName} ${lastName}`; + const name = nickname ?? username ?? fullName ?? nameID; + + // Compatible processing + // When email is provided or nameID is email, use email to find user + // If found, associate the user with the current authenticator + if (email || nameID.match(/^.+@.+\..+$/)) { + const userRepo = this.userCollection.repository; + const user = await userRepo.findOne({ + filter: { email: email || nameID }, + }); + if (user) { + await this.authenticator.addUser(user, { + through: { + uuid: nameID, + }, + }); + return user; + } + } + + return await this.authenticator.findOrCreateUser(nameID, { + nickname: name, + email: email ?? null, + phone: phone ?? null, + }); + } +} diff --git a/packages/plugins/saml/src/server/shared/getSaml.ts b/packages/plugins/saml/src/server/shared/getSaml.ts deleted file mode 100644 index 9272c8586..000000000 --- a/packages/plugins/saml/src/server/shared/getSaml.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { SAML, SamlConfig } from '@node-saml/node-saml'; -import { SAMLProvider } from './types'; - -export const getSaml = (provider: SAMLProvider) => { - const options: SamlConfig = { - entryPoint: provider.loginUrl, - issuer: provider.issuer, - cert: provider.certificate, - audience: false, - wantAuthnResponseSigned: false, - }; - - return new SAML(options); -}; diff --git a/packages/plugins/saml/src/server/shared/types.ts b/packages/plugins/saml/src/server/shared/types.ts deleted file mode 100644 index 9747500a3..000000000 --- a/packages/plugins/saml/src/server/shared/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface SAMLProvider { - title?: string; - clientId?: string; - issuer?: string; - loginUrl?: string; - certificate?: string; - redirectUrl?: string; - enabled?: boolean; -} diff --git a/packages/plugins/sms-auth/README.md b/packages/plugins/sms-auth/README.md new file mode 100644 index 000000000..16e8a088d --- /dev/null +++ b/packages/plugins/sms-auth/README.md @@ -0,0 +1,23 @@ +# SMS Auth + +提供短信登录认证功能。 + +## 依赖 + +- `@nocobase/auth` 认证插件,提供认证相关功能,表、模型、函数复用等。 +- `@nocobase/plugin-verification` 验证码插件,提供短信发送功能。 + +## 使用方法 + +### 新增验证码Provider +插件设置 - Verification/验证码 +新增一个Provider, 并设置为默认方式。 + + + +### 新增SMS认证器 +插件设置 - Authentication/认证 +新增,选择认证类型为SMS,新增一个Authenticator。 + +效果如图: + diff --git a/packages/plugins/sms-auth/client.d.ts b/packages/plugins/sms-auth/client.d.ts new file mode 100755 index 000000000..bd53a2f77 --- /dev/null +++ b/packages/plugins/sms-auth/client.d.ts @@ -0,0 +1,3 @@ +// @ts-nocheck +export * from './lib/client'; +export { default } from './lib/client'; diff --git a/packages/plugins/sms-auth/client.js b/packages/plugins/sms-auth/client.js new file mode 100755 index 000000000..c83e7e450 --- /dev/null +++ b/packages/plugins/sms-auth/client.js @@ -0,0 +1,65 @@ +'use strict'; + +function _getRequireWildcardCache(nodeInterop) { + if (typeof WeakMap !== 'function') return null; + var cacheBabelInterop = new WeakMap(); + var cacheNodeInterop = new WeakMap(); + return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { + return nodeInterop ? cacheNodeInterop : cacheBabelInterop; + })(nodeInterop); +} + +function _interopRequireWildcard(obj, nodeInterop) { + if (!nodeInterop && obj && obj.__esModule) { + return obj; + } + if (obj === null || (typeof obj !== 'object' && typeof obj !== 'function')) { + return { default: obj }; + } + var cache = _getRequireWildcardCache(nodeInterop); + if (cache && cache.has(obj)) { + return cache.get(obj); + } + var newObj = {}; + var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; + for (var key in obj) { + if (key !== 'default' && Object.prototype.hasOwnProperty.call(obj, key)) { + var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; + if (desc && (desc.get || desc.set)) { + Object.defineProperty(newObj, key, desc); + } else { + newObj[key] = obj[key]; + } + } + } + newObj.default = obj; + if (cache) { + cache.set(obj, newObj); + } + return newObj; +} + +var _index = _interopRequireWildcard(require('./lib/client')); + +Object.defineProperty(exports, '__esModule', { + value: true, +}); +var _exportNames = {}; +Object.defineProperty(exports, 'default', { + enumerable: true, + get: function get() { + return _index.default; + }, +}); + +Object.keys(_index).forEach(function (key) { + if (key === 'default' || key === '__esModule') return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _index[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function get() { + return _index[key]; + }, + }); +}); diff --git a/packages/plugins/sms-auth/package.json b/packages/plugins/sms-auth/package.json new file mode 100644 index 000000000..c34efb574 --- /dev/null +++ b/packages/plugins/sms-auth/package.json @@ -0,0 +1,17 @@ +{ + "name": "@nocobase/plugin-sms-auth", + "version": "0.9.4-alpha.2", + "main": "lib/server/index.js", + "devDependencies": { + "@nocobase/server": "0.9.4-alpha.2", + "@nocobase/test": "0.9.4-alpha.2", + "@nocobase/actions": "0.9.4-alpha.2", + "@nocobase/auth": "0.9.4-alpha.2", + "@nocobase/plugin-verification": "0.9.4-alpha.2", + "@nocobase/plugin-auth": "0.9.4-alpha.2" + }, + "displayName": "SMS Auth", + "displayName.zh-CN": "短信登录", + "description": "SMS authentication function", + "description.zh-CN": "提供短信登录认证功能。" +} \ No newline at end of file diff --git a/packages/plugins/sms-auth/server.d.ts b/packages/plugins/sms-auth/server.d.ts new file mode 100755 index 000000000..4d922a91b --- /dev/null +++ b/packages/plugins/sms-auth/server.d.ts @@ -0,0 +1,3 @@ +// @ts-nocheck +export * from './lib/server'; +export { default } from './lib/server'; diff --git a/packages/plugins/sms-auth/server.js b/packages/plugins/sms-auth/server.js new file mode 100755 index 000000000..4f16903a6 --- /dev/null +++ b/packages/plugins/sms-auth/server.js @@ -0,0 +1,65 @@ +'use strict'; + +function _getRequireWildcardCache(nodeInterop) { + if (typeof WeakMap !== 'function') return null; + var cacheBabelInterop = new WeakMap(); + var cacheNodeInterop = new WeakMap(); + return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { + return nodeInterop ? cacheNodeInterop : cacheBabelInterop; + })(nodeInterop); +} + +function _interopRequireWildcard(obj, nodeInterop) { + if (!nodeInterop && obj && obj.__esModule) { + return obj; + } + if (obj === null || (typeof obj !== 'object' && typeof obj !== 'function')) { + return { default: obj }; + } + var cache = _getRequireWildcardCache(nodeInterop); + if (cache && cache.has(obj)) { + return cache.get(obj); + } + var newObj = {}; + var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; + for (var key in obj) { + if (key !== 'default' && Object.prototype.hasOwnProperty.call(obj, key)) { + var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; + if (desc && (desc.get || desc.set)) { + Object.defineProperty(newObj, key, desc); + } else { + newObj[key] = obj[key]; + } + } + } + newObj.default = obj; + if (cache) { + cache.set(obj, newObj); + } + return newObj; +} + +var _index = _interopRequireWildcard(require('./lib/server')); + +Object.defineProperty(exports, '__esModule', { + value: true, +}); +var _exportNames = {}; +Object.defineProperty(exports, 'default', { + enumerable: true, + get: function get() { + return _index.default; + }, +}); + +Object.keys(_index).forEach(function (key) { + if (key === 'default' || key === '__esModule') return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _index[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function get() { + return _index[key]; + }, + }); +}); diff --git a/packages/plugins/sms-auth/src/client/Options.tsx b/packages/plugins/sms-auth/src/client/Options.tsx new file mode 100644 index 000000000..534b609f4 --- /dev/null +++ b/packages/plugins/sms-auth/src/client/Options.tsx @@ -0,0 +1,33 @@ +import { SchemaComponent } from '@nocobase/client'; +import React from 'react'; +import { useAuthTranslation } from './locale'; + +export const Options = () => { + const { t } = useAuthTranslation(); + return ( + + ); +}; diff --git a/packages/plugins/sms-auth/src/client/SigninPage.tsx b/packages/plugins/sms-auth/src/client/SigninPage.tsx new file mode 100644 index 000000000..6788e312d --- /dev/null +++ b/packages/plugins/sms-auth/src/client/SigninPage.tsx @@ -0,0 +1,59 @@ +import { Authenticator, SchemaComponent, useSignIn } from '@nocobase/client'; +import { ISchema } from '@formily/react'; +import React from 'react'; +import VerificationCode from './VerificationCode'; + +const phoneForm: ISchema = { + type: 'object', + name: 'phoneForm', + 'x-component': 'Form', + properties: { + phone: { + type: 'string', + required: true, + 'x-component': 'Input', + 'x-validator': 'phone', + 'x-decorator': 'FormItem', + 'x-component-props': { placeholder: '{{t("Phone")}}', style: {} }, + }, + code: { + type: 'string', + required: true, + 'x-component': 'VerificationCode', + 'x-component-props': { + actionType: 'auth:signIn', + targetFieldName: 'phone', + }, + 'x-decorator': 'FormItem', + }, + actions: { + title: '{{t("Sign in")}}', + type: 'void', + 'x-component': 'Action', + 'x-component-props': { + htmlType: 'submit', + block: true, + type: 'primary', + useAction: '{{ useSMSSignIn }}', + style: { width: '100%' }, + }, + }, + tip: { + type: 'void', + 'x-component': 'div', + 'x-content': '{{t("User will be registered automatically if not exists.", {ns: "sms-auth"})}}', + 'x-component-props': { style: { color: '#ccc' } }, + 'x-visible': '{{ autoSignup }}', + }, + }, +}; + +export const SigninPage = (props: { authenticator: Authenticator }) => { + const authenticator = props.authenticator; + const { name, options } = authenticator; + const autoSignup = !!options?.autoSignup; + const useSMSSignIn = () => { + return useSignIn(name); + }; + return ; +}; diff --git a/packages/plugins/sms-auth/src/client/VerificationCode.tsx b/packages/plugins/sms-auth/src/client/VerificationCode.tsx new file mode 100644 index 000000000..f4b03d7f3 --- /dev/null +++ b/packages/plugins/sms-auth/src/client/VerificationCode.tsx @@ -0,0 +1,63 @@ +import { useTranslation } from 'react-i18next'; +import { css } from '@emotion/css'; +import { useForm } from '@formily/react'; +import { useEffect, useRef, useState } from 'react'; +import { Button, Input, message } from 'antd'; +import React from 'react'; +import { useAPIClient } from '@nocobase/client'; + +export default function VerificationCode({ targetFieldName = 'phone', actionType, value, onChange }) { + const { t } = useTranslation(); + const api = useAPIClient(); + const form = useForm(); + + const [count, setCountdown] = useState(0); + const timer = useRef(null); + + useEffect(() => { + if (count <= 0 && timer.current) { + clearInterval(timer.current); + } + }, [count]); + + async function onGetCode() { + if (count > 0) { + return; + } + try { + const { + data: { data }, + } = await api.resource('verifications').create({ + values: { + type: actionType, + phone: form.values[targetFieldName], + }, + }); + message.success(t('Operation succeeded')); + if (value) { + onChange(''); + } + const expiresIn = data.expiresAt ? Math.ceil((Date.parse(data.expiresAt) - Date.now()) / 1000) : 60; + setCountdown(expiresIn); + timer.current = setInterval(() => { + setCountdown((count) => count - 1); + }, 1000); + } catch (err) { + console.error(err); + } + } + + return ( +
+ + +
+ ); +} diff --git a/packages/plugins/sms-auth/src/client/index.tsx b/packages/plugins/sms-auth/src/client/index.tsx new file mode 100644 index 000000000..7ed89b985 --- /dev/null +++ b/packages/plugins/sms-auth/src/client/index.tsx @@ -0,0 +1,17 @@ +import { OptionsComponentProvider, SigninPageProvider } from '@nocobase/client'; +import React from 'react'; +import { SigninPage } from './SigninPage'; +import { useAuthTranslation } from './locale'; +import { authType } from '../constants'; +import { Options } from './Options'; + +export default (props) => { + const { t } = useAuthTranslation(); + return ( + + + {props.children} + + + ); +}; diff --git a/packages/plugins/sms-auth/src/client/locale/index.ts b/packages/plugins/sms-auth/src/client/locale/index.ts new file mode 100644 index 000000000..ba0c934a5 --- /dev/null +++ b/packages/plugins/sms-auth/src/client/locale/index.ts @@ -0,0 +1,11 @@ +import { i18n } from '@nocobase/client'; +import { useTranslation } from 'react-i18next'; +import zhCN from './zh-CN'; + +export const NAMESPACE = 'sms-auth'; + +i18n.addResources('zh-CN', NAMESPACE, zhCN); + +export function useAuthTranslation() { + return useTranslation(NAMESPACE); +} diff --git a/packages/plugins/sms-auth/src/client/locale/zh-CN.ts b/packages/plugins/sms-auth/src/client/locale/zh-CN.ts new file mode 100644 index 000000000..fbbd872f0 --- /dev/null +++ b/packages/plugins/sms-auth/src/client/locale/zh-CN.ts @@ -0,0 +1,7 @@ +const locale = { + 'Sign in via SMS': '短信登录', + 'User will be registered automatically if not exists.': '用户不存在时将自动注册。', + 'Sign up automatically when the user does not exist': '用户不存在时自动注册', +}; + +export default locale; diff --git a/packages/plugins/sms-auth/src/constants.ts b/packages/plugins/sms-auth/src/constants.ts new file mode 100644 index 000000000..a3342a94a --- /dev/null +++ b/packages/plugins/sms-auth/src/constants.ts @@ -0,0 +1,2 @@ +export const authType = 'SMS'; +export const namespace = require('../package.json').name; diff --git a/packages/plugins/sms-auth/src/index.ts b/packages/plugins/sms-auth/src/index.ts new file mode 100644 index 000000000..7ddad5814 --- /dev/null +++ b/packages/plugins/sms-auth/src/index.ts @@ -0,0 +1 @@ +export { default } from './server'; diff --git a/packages/plugins/sms-auth/src/server/__tests__/signin.test.ts b/packages/plugins/sms-auth/src/server/__tests__/signin.test.ts new file mode 100644 index 000000000..dede8a30f --- /dev/null +++ b/packages/plugins/sms-auth/src/server/__tests__/signin.test.ts @@ -0,0 +1,162 @@ +import { Database, Model, ModelStatic } from '@nocobase/database'; +import { MockServer, mockServer } from '@nocobase/test'; +import SmsAuthPlugin from '../plugin'; +import VerificationPlugin from '@nocobase/plugin-verification'; +import { authType } from '../../constants'; + +class Provider { + constructor(protected plugin: VerificationPlugin, protected options) {} + + async send(receiver: string, data: { [key: string]: any }): Promise {} +} + +describe('signin', () => { + let app: MockServer; + let db: Database; + let verificationModel: ModelStatic; + let authenticator: Model; + let agent; + + beforeAll(async () => { + app = mockServer({ + plugins: ['users', 'auth', 'verification', 'acl'], + }); + app.plugin(SmsAuthPlugin); + await app.loadAndInstall({ clean: true }); + db = app.db; + agent = app.agent(); + + const verificationPlugin: VerificationPlugin = app.getPlugin('verification'); + verificationPlugin.providers.register('fake', Provider as any); + const VerificationProviderRepo = db.getRepository('verifications_providers'); + await VerificationProviderRepo.create({ + values: { + id: 'fake1', + type: 'fake', + default: true, + }, + }); + verificationModel = db.getCollection('verifications').model; + + const authenticatorRepo = db.getRepository('authenticators'); + authenticator = await authenticatorRepo.create({ + values: { + name: 'sms-auth', + authType: authType, + enabled: 1, + }, + }); + }); + + afterAll(async () => { + await db.close(); + }); + + it('should create new user and sign in via phone number', async () => { + let res = await agent.resource('verifications').create({ + values: { + type: 'auth:signIn', + phone: '1', + }, + }); + const verification = await verificationModel.findByPk(res.body.data.id); + res = await agent.set({ 'X-Authenticator': 'sms-auth' }).post('/auth:signIn').send({ + phone: '1', + code: verification.content, + }); + expect(res.statusCode).toBe(401); + + await db.getCollection('verifications').repository.update({ + filter: { + id: verification.id, + }, + values: { + status: 0, + }, + }); + const repo = db.getRepository('authenticators'); + await repo.update({ + filter: { + name: 'sms-auth', + }, + values: { + options: { + public: { + autoSignup: true, + }, + }, + }, + }); + res = await agent.set({ 'X-Authenticator': 'sms-auth' }).post('/auth:signIn').send({ + phone: '1', + code: verification.content, + }); + expect(res.statusCode).toBe(200); + const data = res.body.data; + const token = data.token; + expect(token).toBeDefined(); + expect(res.body.data.user).toBeDefined(); + expect(res.body.data.user.nickname).toEqual('1'); + }); + + it('should sign in via phone number', async () => { + const phone = '2'; + await authenticator.createUser( + { + nickname: phone, + phone: phone, + }, + { + through: { + uuid: phone, + }, + }, + ); + let res = await agent.resource('verifications').create({ + values: { + type: 'auth:signIn', + phone: '2', + }, + }); + const verification = await verificationModel.findByPk(res.body.data.id); + res = await agent.post('/auth:signIn').set({ 'X-Authenticator': 'sms-auth' }).send({ + phone: '2', + code: verification.content, + }); + expect(res.statusCode).toEqual(200); + const data = res.body.data; + const token = data.token; + expect(token).toBeDefined(); + expect(res.body.data.user).toBeDefined(); + expect(res.body.data.user.nickname).toBe('2'); + }); + + it('should sign in via phone number with history data', async () => { + const phone = '3'; + const userRepo = db.getRepository('users'); + const user = await userRepo.create({ + values: { + nickname: phone, + phone: phone, + }, + }); + let res = await agent.resource('verifications').create({ + values: { + type: 'auth:signIn', + phone: '3', + }, + }); + const verification = await verificationModel.findByPk(res.body.data.id); + res = await agent.post('/auth:signIn').set({ 'X-Authenticator': 'sms-auth' }).send({ + phone: '3', + code: verification.content, + }); + expect(res.statusCode).toEqual(200); + const data = res.body.data; + const token = data.token; + expect(token).toBeDefined(); + expect(res.body.data.user).toBeDefined(); + expect(res.body.data.user.id).toBe(user.id); + expect(res.body.data.user.nickname).toBe('3'); + }); +}); diff --git a/packages/plugins/saml/src/server/collections/.gitkeep b/packages/plugins/sms-auth/src/server/collections/.gitkeep similarity index 100% rename from packages/plugins/saml/src/server/collections/.gitkeep rename to packages/plugins/sms-auth/src/server/collections/.gitkeep diff --git a/packages/plugins/sms-auth/src/server/index.ts b/packages/plugins/sms-auth/src/server/index.ts new file mode 100644 index 000000000..b68aea57f --- /dev/null +++ b/packages/plugins/sms-auth/src/server/index.ts @@ -0,0 +1 @@ +export { default } from './plugin'; diff --git a/packages/plugins/sms-auth/src/server/locale/en-US.ts b/packages/plugins/sms-auth/src/server/locale/en-US.ts new file mode 100644 index 000000000..8da957a8b --- /dev/null +++ b/packages/plugins/sms-auth/src/server/locale/en-US.ts @@ -0,0 +1,10 @@ +export default { + 'The email is incorrect, please re-enter': 'The email is incorrect, please re-enter', + 'Please fill in your email address': 'Please fill in your email address', + 'The password is incorrect, please re-enter': 'The password is incorrect, please re-enter', + 'Not a valid cellphone number, please re-enter': 'Not a valid cellphone number, please re-enter', + 'The phone number has been registered, please login directly': + 'The phone number has been registered, please login directly', + 'The phone number is not registered, please register first': + 'The phone number is not registered, please register first', +}; diff --git a/packages/plugins/sms-auth/src/server/locale/index.ts b/packages/plugins/sms-auth/src/server/locale/index.ts new file mode 100644 index 000000000..03bcd4fce --- /dev/null +++ b/packages/plugins/sms-auth/src/server/locale/index.ts @@ -0,0 +1,3 @@ +export { default as enUS } from './en-US'; +export { default as zhCN } from './zh-CN'; +export { default as ptBR } from './pt-BR'; diff --git a/packages/plugins/sms-auth/src/server/locale/ja-JP.ts b/packages/plugins/sms-auth/src/server/locale/ja-JP.ts new file mode 100644 index 000000000..5af98b70f --- /dev/null +++ b/packages/plugins/sms-auth/src/server/locale/ja-JP.ts @@ -0,0 +1,4 @@ +export default { + 'Please fill in your email address': 'メールアドレスを入力してください', + 'The password is incorrect, please re-enter': 'パスワードが正しくありません。再度入力してください。', +}; diff --git a/packages/plugins/sms-auth/src/server/locale/pt-BR.ts b/packages/plugins/sms-auth/src/server/locale/pt-BR.ts new file mode 100644 index 000000000..d6b5f77ec --- /dev/null +++ b/packages/plugins/sms-auth/src/server/locale/pt-BR.ts @@ -0,0 +1,10 @@ +export default { + 'The email is incorrect, please re-enter': 'O e-mail está incorreto, por favor, digite novamente', + 'Please fill in your email address': 'Por favor, preencha o seu endereço de e-mail', + 'The password is incorrect, please re-enter': 'A senha está incorreta, por favor, digite novamente', + 'Not a valid cellphone number, please re-enter': 'Número de celular inválido, por favor, digite novamente', + 'The phone number has been registered, please login directly': + 'O número de celular já está registrado, por favor, faça login diretamente', + 'The phone number is not registered, please register first': + 'O número de celular não está registrado, por favor, registre-se primeiro', +}; diff --git a/packages/plugins/sms-auth/src/server/locale/zh-CN.ts b/packages/plugins/sms-auth/src/server/locale/zh-CN.ts new file mode 100644 index 000000000..8265a9444 --- /dev/null +++ b/packages/plugins/sms-auth/src/server/locale/zh-CN.ts @@ -0,0 +1,9 @@ +export default { + 'The email is incorrect, please re-enter': '邮箱有误,请重新输入', + 'Please fill in your email address': '请填写邮箱', + 'The password is incorrect, please re-enter': '密码有误,请重新输入', + 'Not a valid cellphone number, please re-enter': '不是有效的手机号,请重新输入', + 'The phone number has been registered, please login directly': '手机号已注册,请直接登录', + 'The phone number is not registered, please register first': '手机号未注册,请先注册', + 'Please keep and enable at least one authenticator': '请至少保留并启用一个认证器', +}; diff --git a/packages/plugins/sms-auth/src/server/migrations/20230607180000-sms-authenticator.ts b/packages/plugins/sms-auth/src/server/migrations/20230607180000-sms-authenticator.ts new file mode 100644 index 000000000..f2c0e578b --- /dev/null +++ b/packages/plugins/sms-auth/src/server/migrations/20230607180000-sms-authenticator.ts @@ -0,0 +1,32 @@ +import { Migration } from '@nocobase/server'; +import { authType } from '../../constants'; + +export default class AddBasicAuthMigration extends Migration { + async up() { + const SystemSetting = this.context.db.getRepository('systemSettings'); + const setting = await SystemSetting.findOne(); + const smsAuthEnabled = setting.get('smsAuthEnabled'); + if (!smsAuthEnabled) { + return; + } + const repo = this.context.db.getRepository('authenticators'); + const existed = await repo.count({ + filter: { + authType, + }, + }); + if (existed) { + return; + } + await repo.create({ + values: { + name: 'sms', + authType, + description: 'Sign in with SMS.', + enabled: true, + }, + }); + } + + async down() {} +} diff --git a/packages/plugins/sms-auth/src/server/plugin.ts b/packages/plugins/sms-auth/src/server/plugin.ts new file mode 100644 index 000000000..88ef9f850 --- /dev/null +++ b/packages/plugins/sms-auth/src/server/plugin.ts @@ -0,0 +1,60 @@ +import { InstallOptions, Plugin } from '@nocobase/server'; +import { enUS, zhCN } from './locale'; +import { authType, namespace } from '../constants'; +import { SMSAuth } from './sms-auth'; +import VerificationPlugin from '@nocobase/plugin-verification'; +import { resolve } from 'path'; + +export class SmsAuthPlugin extends Plugin { + afterAdd() {} + + beforeLoad() { + this.app.i18n.addResources('zh-CN', namespace, zhCN); + this.app.i18n.addResources('en-US', namespace, enUS); + + this.app.on('afterLoad', () => { + const verificationPlugin: VerificationPlugin = this.app.getPlugin('verification'); + if (!verificationPlugin) { + this.app.logger.warn('sms-auth: @nocobase/plugin-verification is required'); + return; + } + verificationPlugin.interceptors.register('auth:signIn', { + manual: true, + getReceiver: (ctx) => { + return ctx.action.params.values.phone; + }, + expiresIn: 120, + validate: async (ctx, phone) => { + if (!phone) { + throw new Error(ctx.t('Not a valid cellphone number, please re-enter')); + } + return true; + }, + }); + }); + } + + async load() { + this.db.addMigrations({ + namespace: 'sms-auth', + directory: resolve(__dirname, 'migrations'), + context: { + plugin: this, + }, + }); + + this.app.authManager.registerTypes(authType, { + auth: SMSAuth, + }); + } + + async install(options?: InstallOptions) {} + + async afterEnable() {} + + async afterDisable() {} + + async remove() {} +} + +export default SmsAuthPlugin; diff --git a/packages/plugins/sms-auth/src/server/sms-auth.ts b/packages/plugins/sms-auth/src/server/sms-auth.ts new file mode 100644 index 000000000..2a914f5f1 --- /dev/null +++ b/packages/plugins/sms-auth/src/server/sms-auth.ts @@ -0,0 +1,63 @@ +import { AuthConfig, BaseAuth } from '@nocobase/auth'; +import { Model } from '@nocobase/database'; +import VerificationPlugin from '@nocobase/plugin-verification'; +import { AuthModel } from '@nocobase/plugin-auth'; +import { namespace } from '../constants'; + +export class SMSAuth extends BaseAuth { + constructor(config: AuthConfig) { + const { ctx } = config; + super({ + ...config, + userCollection: ctx.db.getCollection('users'), + }); + } + + async validate() { + const ctx = this.ctx; + const verificationPlugin: VerificationPlugin = ctx.app.getPlugin('verification'); + if (!verificationPlugin) { + throw new Error('sms-auth: @nocobase/plugin-verification is required'); + } + let user: Model; + await verificationPlugin.intercept(ctx, async () => { + const { + values: { phone }, + } = ctx.action.params; + try { + // History data compatible processing + const userRepo = this.userCollection.repository; + user = await userRepo.findOne({ + filter: { phone }, + }); + if (user) { + await this.authenticator.addUser(user, { + through: { + uuid: phone, + }, + }); + return; + } + // New data + const { autoSignup } = this.authenticator.options?.public || {}; + console.log(autoSignup); + const authenticator = this.authenticator as AuthModel; + if (autoSignup) { + user = await authenticator.findOrCreateUser(phone, { + nickname: phone, + phone, + }); + return; + } + user = await authenticator.findUser(phone); + if (!user) { + throw new Error(ctx.t('The phone number is not registered, please register first', { ns: namespace })); + } + } catch (err) { + console.log(err); + throw new Error(err.message); + } + }); + return user; + } +} diff --git a/packages/plugins/users/src/__tests__/actions.test.ts b/packages/plugins/users/src/__tests__/actions.test.ts index 8fc60773b..f096f8218 100644 --- a/packages/plugins/users/src/__tests__/actions.test.ts +++ b/packages/plugins/users/src/__tests__/actions.test.ts @@ -1,4 +1,5 @@ import Database from '@nocobase/database'; +import AuthPlugin from '@nocobase/plugin-auth'; import { mockServer, MockServer } from '@nocobase/test'; import PluginUsers from '../server'; import { userPluginConfig } from './utils'; @@ -18,7 +19,7 @@ describe('actions', () => { process.env.INIT_ROOT_PASSWORD = '123456'; process.env.INIT_ROOT_NICKNAME = 'Test'; app.plugin(PluginUsers, userPluginConfig); - + app.plugin(AuthPlugin); await app.loadAndInstall(); db = app.db; @@ -30,38 +31,33 @@ describe('actions', () => { }); agent = app.agent(); - adminAgent = app.agent().auth( - pluginUser.jwtService.sign({ - userId: adminUser.get('id'), - }), - { type: 'bearer' }, - ); + adminAgent = app.agent().login(adminUser); }); afterEach(async () => { await db.close(); }); - it('should login user with password', async () => { - const { INIT_ROOT_EMAIL, INIT_ROOT_PASSWORD } = process.env; + // it('should login user with password', async () => { + // const { INIT_ROOT_EMAIL, INIT_ROOT_PASSWORD } = process.env; - let response = await agent.resource('users').check(); - expect(response.body.data.id).toBeUndefined(); + // let response = await agent.resource('users').check(); + // expect(response.body.data.id).toBeUndefined(); - response = await agent.post('/users:signin').send({ - email: INIT_ROOT_EMAIL, - password: INIT_ROOT_PASSWORD, - }); + // response = await agent.post('/users:signin').send({ + // email: INIT_ROOT_EMAIL, + // password: INIT_ROOT_PASSWORD, + // }); - expect(response.statusCode).toEqual(200); + // expect(response.statusCode).toEqual(200); - const data = response.body.data; - const token = data.token; - expect(token).toBeDefined(); + // const data = response.body.data; + // const token = data.token; + // expect(token).toBeDefined(); - response = await agent.get('/users:check').set({ Authorization: 'Bearer ' + token }); - expect(response.body.data.id).toBeDefined(); - }); + // response = await agent.get('/users:check').set({ Authorization: 'Bearer ' + token }); + // expect(response.body.data.id).toBeDefined(); + // }); it('update profile', async () => { const res1 = await agent.resource('users').updateProfile({ diff --git a/packages/plugins/users/src/server.ts b/packages/plugins/users/src/server.ts index 1fcf23b5a..716b89ea5 100644 --- a/packages/plugins/users/src/server.ts +++ b/packages/plugins/users/src/server.ts @@ -93,7 +93,7 @@ export default class UsersPlugin extends Plugin { this.app.resourcer.registerActionHandler(`users:${key}`, action); } - this.app.resourcer.use(parseToken, { tag: 'parseToken' }); + // this.app.resourcer.use(parseToken, { tag: 'parseToken' }); this.app.acl.addFixedParams('users', 'destroy', () => { return { diff --git a/packages/plugins/verification/src/server/Plugin.ts b/packages/plugins/verification/src/server/Plugin.ts index f5baac1df..503a53754 100644 --- a/packages/plugins/verification/src/server/Plugin.ts +++ b/packages/plugins/verification/src/server/Plugin.ts @@ -70,13 +70,14 @@ export default class VerificationPlugin extends Plugin { // } // }); - - await next(); - - // or delete - await item.update({ - status: CODE_STATUS_USED, - }); + try { + await next(); + } finally { + // or delete + await item.update({ + status: CODE_STATUS_USED, + }); + } }; async install() { diff --git a/packages/plugins/workflow/src/server/__tests__/instructions/manual.test.ts b/packages/plugins/workflow/src/server/__tests__/instructions/manual.test.ts index 10b968dca..d8d5f7d1b 100644 --- a/packages/plugins/workflow/src/server/__tests__/instructions/manual.test.ts +++ b/packages/plugins/workflow/src/server/__tests__/instructions/manual.test.ts @@ -20,8 +20,9 @@ describe('workflow > instructions > manual', () => { beforeEach(async () => { app = await getApp({ - plugins: ['users'], + plugins: ['users', 'auth'], }); + await app.getPlugin('auth').install(); agent = app.agent(); db = app.db; WorkflowModel = db.getCollection('workflows').model; @@ -36,14 +37,7 @@ describe('workflow > instructions > manual', () => { ]); const userPlugin = app.getPlugin('users') as UserPlugin; - userAgents = users.map((user) => - app.agent().auth( - userPlugin.jwtService.sign({ - userId: user.id, - }), - { type: 'bearer' }, - ), - ); + userAgents = users.map((user) => app.agent().login(user)); workflow = await WorkflowModel.create({ enabled: true, diff --git a/packages/presets/nocobase/package.json b/packages/presets/nocobase/package.json index 8f0ae09ca..fd17a84b6 100644 --- a/packages/presets/nocobase/package.json +++ b/packages/presets/nocobase/package.json @@ -7,6 +7,7 @@ "dependencies": { "@nocobase/plugin-acl": "0.9.4-alpha.2", "@nocobase/plugin-audit-logs": "0.9.4-alpha.2", + "@nocobase/plugin-auth": "0.9.4-alpha.2", "@nocobase/plugin-charts": "0.9.4-alpha.2", "@nocobase/plugin-china-region": "0.9.4-alpha.2", "@nocobase/plugin-client": "0.9.4-alpha.2", @@ -26,6 +27,7 @@ "@nocobase/plugin-multi-app-share-collection": "0.9.4-alpha.2", "@nocobase/plugin-oidc": "0.9.4-alpha.2", "@nocobase/plugin-saml": "0.9.4-alpha.2", + "@nocobase/plugin-sms-auth": "0.9.4-alpha.2", "@nocobase/plugin-sample-hello": "0.9.4-alpha.2", "@nocobase/plugin-sequence-field": "0.9.4-alpha.2", "@nocobase/plugin-snapshot-field": "0.9.4-alpha.2", @@ -43,4 +45,4 @@ "directory": "packages/presets/nocobase" }, "gitHead": "ce588eefb0bfc50f7d5bbee575e0b5e843bf6644" -} +} \ No newline at end of file diff --git a/packages/presets/nocobase/src/index.ts b/packages/presets/nocobase/src/index.ts index 9674af058..305b60d19 100644 --- a/packages/presets/nocobase/src/index.ts +++ b/packages/presets/nocobase/src/index.ts @@ -24,6 +24,8 @@ export class PresetNocoBase extends Plugin { 'iframe-block', 'formula-field', 'charts', + 'auth', + 'sms-auth', ]; localPlugins = [ diff --git a/yarn.lock b/yarn.lock index e1def21f8..6e9c46e5e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3110,7 +3110,6 @@ "@formily/antd@2.2.24": version "2.2.24" resolved "https://registry.yarnpkg.com/@formily/antd/-/antd-2.2.24.tgz#d536cba6d4691b9c2089efd818cb85681a6d4c9b" - integrity sha512-jMrbg63FMQvkwT+y4mum2DE2mRlSuqwd1cuvWiUsH2RkaQM/SQhVdwigIsKFwzj69ujN75IeQCmAhZJIBFoLvQ== dependencies: "@formily/core" "2.2.24" "@formily/grid" "2.2.24" @@ -3126,7 +3125,6 @@ "@formily/core@2.2.24": version "2.2.24" resolved "https://registry.yarnpkg.com/@formily/core/-/core-2.2.24.tgz#b96b72def806ee4c71c0a0279b678ffcc23828bf" - integrity sha512-FjyhtSwSxSK18C5L+D2U2uqkVh5J0BnWmk/OyvOGNoQ3gdCHJFyH8gr6NNt11B8xL2oXKIEpaC0nw8iOcGf8eA== dependencies: "@formily/reactive" "2.2.24" "@formily/shared" "2.2.24" @@ -3135,7 +3133,6 @@ "@formily/grid@2.2.24": version "2.2.24" resolved "https://registry.yarnpkg.com/@formily/grid/-/grid-2.2.24.tgz#e5e1163ef9597a4417fc2f2f4e438d48c0d3dd59" - integrity sha512-w0IWWYDG8NwzdrLRsbUkX/L5OuFATTwyJc+tswtxVi2nrF9hZeHSYwvMh4dBI9rF2l0LKORvnVnk1O/kufnWug== dependencies: "@formily/reactive" "2.2.24" "@juggle/resize-observer" "^3.3.1" @@ -3143,7 +3140,6 @@ "@formily/json-schema@2.2.24": version "2.2.24" resolved "https://registry.yarnpkg.com/@formily/json-schema/-/json-schema-2.2.24.tgz#b23b9ae18a24fbcb716cd0e23677c8597abc5689" - integrity sha512-3aEMEO5D0B1p5rM/lzWmhx3rYqid4OKzCXAdxBsyicacFK9dVqxlOBt8d6auOsppMZ675q52jSVDxNSgh4xKqg== dependencies: "@formily/core" "2.2.24" "@formily/reactive" "2.2.24" @@ -3152,12 +3148,10 @@ "@formily/path@2.2.24": version "2.2.24" resolved "https://registry.yarnpkg.com/@formily/path/-/path-2.2.24.tgz#1c15352dc72f78ef3e8924633a9d8a8182364d6b" - integrity sha512-DrRBIPvlqGr7+xQ/74MY/j7WSbUzvWeHOsjfXTIllbijKAdUm+DovpQuHMXR16I3gnuQ8CvyMzCizP9RKVnhzw== "@formily/react@2.2.24": version "2.2.24" resolved "https://registry.yarnpkg.com/@formily/react/-/react-2.2.24.tgz#415ca8df2c445ba43fe596d0a6e8bd4236abe8d6" - integrity sha512-Bq5LQzK9u0E8BBwHqWY1jXtbdVAJV/gMkZAssAHVXu01YvDUVugHXcbxuzKZKY5MrZFIyM7Jod0TbhjEPr2bhA== dependencies: "@formily/core" "2.2.24" "@formily/json-schema" "2.2.24" @@ -3170,7 +3164,6 @@ "@formily/reactive-react@2.2.24": version "2.2.24" resolved "https://registry.yarnpkg.com/@formily/reactive-react/-/reactive-react-2.2.24.tgz#187e6f07b1aab09f41134c4335200b38afdcc67c" - integrity sha512-MSS2ug+19+GzVJFhWjwOp6QWe/Bc3xC9YXYB7lfriJBQCC5lGlX0xaOZNkOH4yFz9OvSR59wwManOpOh329/EQ== dependencies: "@formily/reactive" "2.2.24" hoist-non-react-statics "^3.3.2" @@ -3178,12 +3171,10 @@ "@formily/reactive@2.2.24": version "2.2.24" resolved "https://registry.yarnpkg.com/@formily/reactive/-/reactive-2.2.24.tgz#758b0fb26568970cbd326e1abb86f0264713f0c1" - integrity sha512-b3+43v7pmUCC8C57XO27M0x+2aE8k6lNndaOuo15Du+tLQyr+g+JBpEZsbIJfy9ScgdK6mIttxIhJiU+xUAmvg== "@formily/shared@2.2.24": version "2.2.24" resolved "https://registry.yarnpkg.com/@formily/shared/-/shared-2.2.24.tgz#f7939d8a36796db7fce580c2e6c52d04fed1b13b" - integrity sha512-PU3gBgbybSU6yv/Act2fn5sYnYTuiVJtpiYEMQGyn7wuWguOQfOpIkuaVim4h64PARO8WmnjENeDl5R5evgIYQ== dependencies: "@formily/path" "2.2.24" camel-case "^4.1.1" @@ -3196,7 +3187,6 @@ "@formily/validator@2.2.24": version "2.2.24" resolved "https://registry.yarnpkg.com/@formily/validator/-/validator-2.2.24.tgz#03fc909fe93694e895d73e6d9e445a9f89bc1d51" - integrity sha512-EDZiArnLdDbp2k7/PHknjkX5FgsJXoqWbBrrhXCyzwC2ebBG4VTxWZUB+MWGT17A77Tl7xPsY7JPpJohcuQ+4Q== dependencies: "@formily/shared" "2.2.24" @@ -4799,7 +4789,6 @@ "@testing-library/react@^12.1.2", "@testing-library/react@^12.1.5": version "12.1.5" resolved "https://registry.npmmirror.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b" - integrity sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg== dependencies: "@babel/runtime" "^7.12.5" "@testing-library/dom" "^8.0.0" @@ -4808,7 +4797,6 @@ "@testing-library/user-event@^14.4.3": version "14.4.3" resolved "https://registry.npmmirror.com/@testing-library/user-event/-/user-event-14.4.3.tgz#af975e367743fa91989cd666666aec31a8f50591" - integrity sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q== "@tootallnate/once@1": version "1.1.2" @@ -5742,7 +5730,6 @@ "@vitest/expect@0.32.0": version "0.32.0" resolved "https://registry.npmmirror.com/@vitest/expect/-/expect-0.32.0.tgz#5b10cdc822d54cb870aef917462fbf847202e4e0" - integrity sha512-VxVHhIxKw9Lux+O9bwLEEk2gzOUe93xuFHy9SzYWnnoYZFYg1NfBtnfnYWiJN7yooJ7KNElCK5YtA7DTZvtXtg== dependencies: "@vitest/spy" "0.32.0" "@vitest/utils" "0.32.0" @@ -5751,7 +5738,6 @@ "@vitest/runner@0.32.0": version "0.32.0" resolved "https://registry.npmmirror.com/@vitest/runner/-/runner-0.32.0.tgz#d2a391bf4bb373e324cf2f372d8bb913ab323f08" - integrity sha512-QpCmRxftHkr72xt5A08xTEs9I4iWEXIOCHWhQQguWOKE4QH7DXSKZSOFibuwEIMAD7G0ERvtUyQn7iPWIqSwmw== dependencies: "@vitest/utils" "0.32.0" concordance "^5.0.4" @@ -5761,7 +5747,6 @@ "@vitest/snapshot@0.32.0": version "0.32.0" resolved "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-0.32.0.tgz#49c6a08da12ab903ab4582386c97d70b293bc644" - integrity sha512-yCKorPWjEnzpUxQpGlxulujTcSPgkblwGzAUEL+z01FTUg/YuCDZ8dxr9sHA08oO2EwxzHXNLjQKWJ2zc2a19Q== dependencies: magic-string "^0.30.0" pathe "^1.1.0" @@ -5770,14 +5755,12 @@ "@vitest/spy@0.32.0": version "0.32.0" resolved "https://registry.npmmirror.com/@vitest/spy/-/spy-0.32.0.tgz#3ccb4e6dd4cbda7bb837c467d8405e2f8cc107a2" - integrity sha512-MruAPlM0uyiq3d53BkwTeShXY0rYEfhNGQzVO5GHBmmX3clsxcWp79mMnkOVcV244sNTeDcHbcPFWIjOI4tZvw== dependencies: tinyspy "^2.1.0" "@vitest/utils@0.32.0": version "0.32.0" resolved "https://registry.npmmirror.com/@vitest/utils/-/utils-0.32.0.tgz#ffbc023083e0ef8401a3562086de0ff1b0599635" - integrity sha512-53yXunzx47MmbuvcOPpLaVljHaeSu1G2dHdmy7+9ngMnQIkBQcvwOcoclWFnxDMxFbnq8exAfh3aKSZaK71J5A== dependencies: concordance "^5.0.4" loupe "^2.3.6" @@ -13671,9 +13654,9 @@ jest@^26.0.0: import-local "^3.0.2" jest-cli "^26.6.3" -jose@^4.10.0: - version "4.13.1" - resolved "https://registry.yarnpkg.com/jose/-/jose-4.13.1.tgz#449111bb5ab171db85c03f1bd2cb1647ca06db1c" +jose@^4.14.1: + version "4.14.4" + resolved "https://registry.yarnpkg.com/jose/-/jose-4.14.4.tgz#59e09204e2670c3164ee24cbfe7115c6f8bff9ca" jquery-mousewheel@^3.1.13: version "3.1.13" @@ -13759,7 +13742,6 @@ jscodeshift@^0.6.2: jsdom-worker@^0.3.0: version "0.3.0" resolved "https://registry.npmmirror.com/jsdom-worker/-/jsdom-worker-0.3.0.tgz#aff32ec089d17f56a5a344a426b6fb2f56e7de54" - integrity sha512-nlPmN0i93+e6vxzov8xqLMR+MBs/TAYeSviehivzqovHH0AgooVx9pQ/otrygASppPvdR+V9Jqx5SMe8+FcADg== dependencies: mitt "^3.0.0" uuid-v4 "^0.1.0" @@ -15316,7 +15298,6 @@ mississippi@^2.0.0: mitt@^3.0.0: version "3.0.0" resolved "https://registry.npmmirror.com/mitt/-/mitt-3.0.0.tgz#69ef9bd5c80ff6f57473e8d89326d01c414be0bd" - integrity sha512-7dX2/10ITVyqh4aOSVI9gdape+t9l2/8QxHrFmUXu4EEUpdlxl6RudZUPZoc+zuY2hk1j7XxVroIVIan/pD/SQ== mixin-deep@^1.2.0: version "1.3.2" @@ -15525,6 +15506,10 @@ nan@^2.12.1: version "2.17.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.17.0.tgz#c0150a2368a182f033e9aa5195ec76ea41a199cb" +nanoid@3.3.4: + version "3.3.4" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" + nanoid@^2.1.0: version "2.1.11" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-2.1.11.tgz#ec24b8a758d591561531b4176a01e3ab4f0f0280" @@ -16024,7 +16009,7 @@ object-copy@^0.1.0: define-property "^0.2.5" kind-of "^3.0.3" -object-hash@^2.0.1: +object-hash@^2.0.1, object-hash@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5" @@ -16108,9 +16093,9 @@ object.values@^1.1.0, object.values@^1.1.1, object.values@^1.1.5, object.values@ define-properties "^1.1.4" es-abstract "^1.20.4" -oidc-token-hash@^5.0.1: - version "5.0.2" - resolved "https://registry.yarnpkg.com/oidc-token-hash/-/oidc-token-hash-5.0.2.tgz#f9ca7f7f1f92d721a2973e66b7430cb52a486648" +oidc-token-hash@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz#9a229f0a1ce9d4fc89bcaee5478c97a889e7b7b6" on-finished@^2.3.0: version "2.4.1" @@ -16156,14 +16141,14 @@ open@^6.3.0: dependencies: is-wsl "^1.1.0" -openid-client@^5.3.0: - version "5.4.0" - resolved "https://registry.yarnpkg.com/openid-client/-/openid-client-5.4.0.tgz#77f1cda14e2911446f16ea3f455fc7c405103eac" +openid-client@^5.4.2: + version "5.4.2" + resolved "https://registry.yarnpkg.com/openid-client/-/openid-client-5.4.2.tgz#8692bcc2a40ef3426c5ba5c11f7493599e93ccc7" dependencies: - jose "^4.10.0" + jose "^4.14.1" lru-cache "^6.0.0" - object-hash "^2.0.1" - oidc-token-hash "^5.0.1" + object-hash "^2.2.0" + oidc-token-hash "^5.0.3" opt-cli@1.5.1: version "1.5.1" @@ -21869,7 +21854,6 @@ utility@^1.16.1, utility@^1.17.0, utility@^1.8.0: uuid-v4@^0.1.0: version "0.1.0" resolved "https://registry.npmmirror.com/uuid-v4/-/uuid-v4-0.1.0.tgz#62d7b310406f6cecfea1528c69f1e8e0bcec5a3a" - integrity sha512-m11RYDtowtAIihBXMoGajOEKpAXrKbpKlpmxqyztMYQNGSY5nZAZ/oYch/w2HNS1RMA4WLGcZvuD8/wFMuCEzA== uuid@^3.2.1, uuid@^3.3.2: version "3.4.0" @@ -22014,7 +21998,6 @@ vinyl@^2.0.0, vinyl@^2.1.0: vite-node@0.32.0: version "0.32.0" resolved "https://registry.npmmirror.com/vite-node/-/vite-node-0.32.0.tgz#8ee54539fa75d1271adaa9788c8ba526480f4519" - integrity sha512-220P/y8YacYAU+daOAqiGEFXx2A8AwjadDzQqos6wSukjvvTWNqleJSwoUn0ckyNdjHIKoxn93Nh1vWBqEKr3Q== dependencies: cac "^6.7.14" debug "^4.3.4" @@ -22036,7 +22019,6 @@ vite-node@0.32.0: vitest@^0.32.0: version "0.32.0" resolved "https://registry.npmmirror.com/vitest/-/vitest-0.32.0.tgz#125212b8abbfdae047fa72e516bc7257fd0a67db" - integrity sha512-SW83o629gCqnV3BqBnTxhB10DAwzwEx3z+rqYZESehUB+eWsJxwcBQx7CKy0otuGMJTYh7qCVuUX23HkftGl/Q== dependencies: "@types/chai" "^4.3.5" "@types/chai-subset" "^1.3.3"