feat(auth): support custom authentication (#2007)
* feat(auth): init auth package & collection * feat(auth): register * feat(auth): use authenticator * feat(auth): mapRoles * feat(auth): refactor * feat(auth): base auth class * feat(auth): add plugin * chore(auth): test * chore(auth): add test cases * feat(auth): authenticators pane * chore(auth): custom hook useAuthTypes * feat(auth): authenticator pane * chore(auth): store options schema using context * feat(auth): signInPage provider * feat(auth): signUpPage provider * chore(auth): solve build errors * chore(auth): add dependency * chore(auth): remove dependency cycles * chore(auth): add plugin-auth to preset * chore(auth): fix test * feat(auth): authenticator enable status * fix(test): fix test using new authentication * feat(auth): migration, set up basic auth * chore(auth): can set options ui by component * fix(test): workflow manunal.test * fix(test): typo * feat(auth): support multi-language * chore(auth): imporve code * chore(auth): hide button if no configuration * chore(auth): readme * chore(auth): remove allowSignup prop * chore(auth): move configure pane to edit form * fix(auth): jwt options bug * feat(auth): init sms-auth * chore(auth): at least authenticator required * chore(auth): add test * feat(auth): support sms auth * fix(auth): fix test * chore(auth): move findOrCreateUser to AuthModel * chore(auth): history compatible processing * feat(auth): support SAML auth * chore(auth): saml auth list * chore(saml-auth): improve ui * Merge branch 'main' into feat/authentication * chore(auth): improve code * fix(saml-auth): fix bug * fix(saml-auth): fix saml options * chore(saml-auth): compatible processing && ut * fix(auth): signin page bug * chore(auth): saml compatible processing * feat(auth): oidc-auth * fix(oidc-auth): bug * fix(oidc-auth): bug * fix(auth): fix test * chore(auth): filter enabled authenticator * chore(oidc): add field map * chore(auth): update readme * docs(auth): create sms-auth readme * feat(auth): allow signup config * test(auth): fix test * feat(auth): allow saml and oidc use http * chore(oidc-auth): extends timeout * docs(auth): update readme * feat(auth): support sort * docs(saml): update readme * feat(auth): support sort all authenticator * Merge branch 'main' into feat/authentication * Merge branch 'main' into feat/authentication * feat: improve code * docs(auth): add doc * Merge branch 'main' into feat/authentication * chore: update yarn.lock * feat: improve code * chore(acl): write role to acl if it exists in database and not found … (#2001) * chore(acl): write role to acl if it exists in database and not found in acl * fix: test * fix: eager load with nested association (#2002) * chore: upgrade vitest * chore: edit * refactor: auth class * fix: set options * chore(acl): write role to acl if it exists in database and not found … (#2001) * chore(acl): write role to acl if it exists in database and not found in acl * fix: test * fix: eager load with nested association (#2002) * chore: upgrade vitest * chore: add migrations * test: fix api-client test * chore: add sms-auth * feat: avoid no permission after auth type disabled * fix: translation --------- Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
parent
0c150eaf9b
commit
24601aa66f
@ -2,3 +2,7 @@ import dotenv from 'dotenv';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
dotenv.config({ path: path.resolve(process.cwd(), '.env.test') });
|
dotenv.config({ path: path.resolve(process.cwd(), '.env.test') });
|
||||||
|
|
||||||
|
// 当前jest版本低,polyfill
|
||||||
|
import { TextEncoder, TextDecoder } from 'util';
|
||||||
|
Object.assign(global, { TextDecoder, TextEncoder });
|
||||||
|
1
packages/app/client/src/plugins/auth.ts
Normal file
1
packages/app/client/src/plugins/auth.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from '@nocobase/plugin-auth/client';
|
1
packages/app/client/src/plugins/sms-auth.ts
Normal file
1
packages/app/client/src/plugins/sms-auth.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from '@nocobase/plugin-sms-auth/client';
|
4
packages/core/auth/.npmignore
Normal file
4
packages/core/auth/.npmignore
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
*.log
|
||||||
|
__tests__
|
||||||
|
src
|
201
packages/core/auth/LICENSE
Normal file
201
packages/core/auth/LICENSE
Normal file
@ -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.
|
19
packages/core/auth/package.json
Normal file
19
packages/core/auth/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
50
packages/core/auth/src/__tests__/auth-manager.test.ts
Normal file
50
packages/core/auth/src/__tests__/auth-manager.test.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Context } from '@nocobase/actions';
|
||||||
|
import { Auth, AuthManager } from '@nocobase/auth';
|
||||||
|
import { Model } from '@nocobase/database';
|
||||||
|
|
||||||
|
class MockStorer {
|
||||||
|
elements: Map<string, any> = 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);
|
||||||
|
});
|
||||||
|
});
|
20
packages/core/auth/src/actions.ts
Normal file
20
packages/core/auth/src/actions.ts
Normal file
@ -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;
|
99
packages/core/auth/src/auth-manager.ts
Normal file
99
packages/core/auth/src/auth-manager.ts
Normal file
@ -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<Model>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthManagerOptions = {
|
||||||
|
authKey: string;
|
||||||
|
default?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthConfig = {
|
||||||
|
auth: AuthExtend<Auth>; // The authentication class.
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AuthManager {
|
||||||
|
protected options: AuthManagerOptions;
|
||||||
|
protected authTypes: Registry<AuthConfig> = 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<Auth>} 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();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
44
packages/core/auth/src/auth.ts
Normal file
44
packages/core/auth/src/auth.ts
Normal file
@ -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<T extends Auth> = new (config: AuthConfig) => T;
|
||||||
|
|
||||||
|
interface IAuth {
|
||||||
|
user: Model;
|
||||||
|
// Check the authenticaiton status and return the current user.
|
||||||
|
check(): Promise<Model>;
|
||||||
|
signIn(): Promise<any>;
|
||||||
|
signUp(): Promise<any>;
|
||||||
|
signOut(): Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<any> {}
|
||||||
|
async signUp(): Promise<any> {}
|
||||||
|
async signOut(): Promise<any> {}
|
||||||
|
}
|
74
packages/core/auth/src/base/auth.ts
Normal file
74
packages/core/auth/src/base/auth.ts
Normal file
@ -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<Model> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
40
packages/core/auth/src/base/jwt-service.ts
Normal file
40
packages/core/auth/src/base/jwt-service.ts
Normal file
@ -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<any> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
jwt.verify(token, this.secret(), (err: any, decoded: any) => {
|
||||||
|
if (err) {
|
||||||
|
return reject(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(decoded);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
4
packages/core/auth/src/index.ts
Normal file
4
packages/core/auth/src/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from './auth-manager';
|
||||||
|
export * from './auth';
|
||||||
|
export * from './base/auth';
|
||||||
|
export * from './actions';
|
@ -30,8 +30,8 @@ import { ErrorFallback } from '../schema-component/antd/error-fallback';
|
|||||||
import { SchemaInitializerProvider } from '../schema-initializer';
|
import { SchemaInitializerProvider } from '../schema-initializer';
|
||||||
import { BlockTemplateDetails, BlockTemplatePage } from '../schema-templates';
|
import { BlockTemplateDetails, BlockTemplatePage } from '../schema-templates';
|
||||||
import { SystemSettingsProvider } from '../system-settings';
|
import { SystemSettingsProvider } from '../system-settings';
|
||||||
import { SigninPage, SignupPage } from '../user';
|
import { SigninPage, SignupPage } from '../auth';
|
||||||
import { SigninPageExtensionProvider } from '../user/SigninPageExtension';
|
import { SigninPageExtensionProvider } from '../auth/SigninPageExtension';
|
||||||
import { compose } from './compose';
|
import { compose } from './compose';
|
||||||
|
|
||||||
export interface ApplicationOptions {
|
export interface ApplicationOptions {
|
||||||
|
23
packages/core/client/src/auth/OptionsComponent.tsx
Normal file
23
packages/core/client/src/auth/OptionsComponent.tsx
Normal file
@ -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 <OptionsComponentContext.Provider value={components}>{props.children}</OptionsComponentContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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) : <></>;
|
||||||
|
};
|
158
packages/core/client/src/auth/SigninPage.tsx
Normal file
158
packages/core/client/src/auth/SigninPage.tsx
Normal file
@ -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 <SigninPageContext.Provider value={components}>{props.children}</SigninPageContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Authenticator = {
|
||||||
|
name: string;
|
||||||
|
authType: string;
|
||||||
|
title?: string;
|
||||||
|
options?: {
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
sort?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuthenticatorsContext = createContext<Authenticator[]>([]);
|
||||||
|
|
||||||
|
export function useRedirect(next = '/admin') {
|
||||||
|
const location = useLocation<any>();
|
||||||
|
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<Authenticator[]>([]);
|
||||||
|
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 <div style={{ color: '#ccc' }}>{t('Oops! No authentication methods available.')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthenticatorsContext.Provider value={authenticators}>
|
||||||
|
<Space
|
||||||
|
direction="vertical"
|
||||||
|
className={css`
|
||||||
|
display: flex;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{tabs.length > 1 ? (
|
||||||
|
<Tabs>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<Tabs.TabPane tab={tab.tabTitle} key={tab.name}>
|
||||||
|
{tab.component}
|
||||||
|
</Tabs.TabPane>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
) : tabs.length ? (
|
||||||
|
<div>{tabs[0].component}</div>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
<Space
|
||||||
|
direction="vertical"
|
||||||
|
className={css`
|
||||||
|
display: flex;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{signinExtension}
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</AuthenticatorsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
40
packages/core/client/src/auth/SigninPageExtension.tsx
Normal file
40
packages/core/client/src/auth/SigninPageExtension.tsx
Normal file
@ -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<SigninPageExtensionContextValue>({ 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 (
|
||||||
|
<SigninPageExtensionContext.Provider value={{ components }}>{props.children}</SigninPageExtensionContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
75
packages/core/client/src/auth/SignupPage.tsx
Normal file
75
packages/core/client/src/auth/SignupPage.tsx
Normal file
@ -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 <SignupPageContext.Provider value={components}>{props.children}</SignupPageContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<div style={{ color: '#ccc' }}>{t('Oops! The authentication type does not allow sign-up.')}</div>
|
||||||
|
<Link to="/signin">{t('Return')}</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return createElement(signUpPages[authType].component, { name });
|
||||||
|
};
|
4
packages/core/client/src/auth/index.ts
Normal file
4
packages/core/client/src/auth/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from './SigninPage';
|
||||||
|
export * from './SignupPage';
|
||||||
|
export * from './OptionsComponent';
|
||||||
|
export * from './SigninPageExtension';
|
@ -362,11 +362,16 @@ export const useUpdateAction = () => {
|
|||||||
await form.submit();
|
await form.submit();
|
||||||
field.data = field.data || {};
|
field.data = field.data || {};
|
||||||
field.data.loading = true;
|
field.data.loading = true;
|
||||||
|
try {
|
||||||
await resource.update({ filterByTk, values: form.values });
|
await resource.update({ filterByTk, values: form.values });
|
||||||
ctx.setVisible(false);
|
ctx.setVisible(false);
|
||||||
await form.reset();
|
await form.reset();
|
||||||
field.data.loading = false;
|
|
||||||
refresh();
|
refresh();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
} finally {
|
||||||
|
field.data.loading = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -6,6 +6,7 @@ export * from './api-client';
|
|||||||
export * from './appInfo';
|
export * from './appInfo';
|
||||||
export * from './application';
|
export * from './application';
|
||||||
export * from './async-data-provider';
|
export * from './async-data-provider';
|
||||||
|
export * from './auth';
|
||||||
export * from './block-provider';
|
export * from './block-provider';
|
||||||
export * from './board';
|
export * from './board';
|
||||||
export * from './china-region';
|
export * from './china-region';
|
||||||
|
@ -757,6 +757,7 @@ export default {
|
|||||||
'Form data templates': '表单数据模板',
|
'Form data templates': '表单数据模板',
|
||||||
'Data template': '数据模板',
|
'Data template': '数据模板',
|
||||||
|
|
||||||
|
'No configuration available.': '无可配置项。',
|
||||||
'Reload application': '重载应用',
|
'Reload application': '重载应用',
|
||||||
'The application is reloading, please do not close the page.': '应用正在重新加载,请勿关闭页面。',
|
'The application is reloading, please do not close the page.': '应用正在重新加载,请勿关闭页面。',
|
||||||
'Application reloading': '应用重新加载中',
|
'Application reloading': '应用重新加载中',
|
||||||
|
@ -293,7 +293,7 @@ export const PluginCard = (props: { data: IPluginData }) => {
|
|||||||
onChange={async (checked, e) => {
|
onChange={async (checked, e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
Modal.warn({
|
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.'),
|
content: t('The application is reloading, please do not close the page.'),
|
||||||
okButtonProps: {
|
okButtonProps: {
|
||||||
style: {
|
style: {
|
||||||
|
@ -121,20 +121,20 @@ const schema: ISchema = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
allowSignUp: {
|
// allowSignUp: {
|
||||||
type: 'boolean',
|
// type: 'boolean',
|
||||||
default: true,
|
// default: true,
|
||||||
'x-content': '{{t("Allow sign up")}}',
|
// 'x-content': '{{t("Allow sign up")}}',
|
||||||
'x-component': 'Checkbox',
|
// 'x-component': 'Checkbox',
|
||||||
'x-decorator': 'FormItem',
|
// 'x-decorator': 'FormItem',
|
||||||
},
|
// },
|
||||||
smsAuthEnabled: {
|
// smsAuthEnabled: {
|
||||||
type: 'boolean',
|
// type: 'boolean',
|
||||||
default: false,
|
// default: false,
|
||||||
'x-content': '{{t("Enable SMS authentication")}}',
|
// 'x-content': '{{t("Enable SMS authentication")}}',
|
||||||
'x-component': 'Checkbox',
|
// 'x-component': 'Checkbox',
|
||||||
'x-decorator': 'FormItem',
|
// 'x-decorator': 'FormItem',
|
||||||
},
|
// },
|
||||||
footer1: {
|
footer1: {
|
||||||
type: 'void',
|
type: 'void',
|
||||||
'x-component': 'Action.Drawer.Footer',
|
'x-component': 'Action.Drawer.Footer',
|
||||||
@ -227,20 +227,20 @@ const schema2: ISchema = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
allowSignUp: {
|
// allowSignUp: {
|
||||||
type: 'boolean',
|
// type: 'boolean',
|
||||||
default: true,
|
// default: true,
|
||||||
'x-content': '{{t("Allow sign up")}}',
|
// 'x-content': '{{t("Allow sign up")}}',
|
||||||
'x-component': 'Checkbox',
|
// 'x-component': 'Checkbox',
|
||||||
'x-decorator': 'FormItem',
|
// 'x-decorator': 'FormItem',
|
||||||
},
|
// },
|
||||||
smsAuthEnabled: {
|
// smsAuthEnabled: {
|
||||||
type: 'boolean',
|
// type: 'boolean',
|
||||||
default: false,
|
// default: false,
|
||||||
'x-content': '{{t("Enable SMS authentication")}}',
|
// 'x-content': '{{t("Enable SMS authentication")}}',
|
||||||
'x-component': 'Checkbox',
|
// 'x-component': 'Checkbox',
|
||||||
'x-decorator': 'FormItem',
|
// 'x-decorator': 'FormItem',
|
||||||
},
|
// },
|
||||||
footer1: {
|
footer1: {
|
||||||
type: 'void',
|
type: 'void',
|
||||||
'x-component': 'ActionBar',
|
'x-component': 'ActionBar',
|
||||||
|
@ -110,8 +110,7 @@ export const CurrentUser = () => {
|
|||||||
<Menu.Item
|
<Menu.Item
|
||||||
key="signout"
|
key="signout"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await api.resource('users').signout();
|
await api.auth.signOut();
|
||||||
api.auth.setToken(null);
|
|
||||||
history.push('/signin');
|
history.push('/signin');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
@ -12,7 +12,7 @@ export const useCurrentUserContext = () => {
|
|||||||
export const CurrentUserProvider = (props) => {
|
export const CurrentUserProvider = (props) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const result = useRequest({
|
const result = useRequest({
|
||||||
url: 'users:check',
|
url: 'auth:check',
|
||||||
});
|
});
|
||||||
if (result.loading) {
|
if (result.loading) {
|
||||||
return <Spin />;
|
return <Spin />;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
export * from './CurrentUser';
|
export * from './CurrentUser';
|
||||||
export * from './CurrentUserProvider';
|
export * from './CurrentUserProvider';
|
||||||
export * from './SigninPage';
|
// export * from './SigninPage';
|
||||||
export * from './SignupPage';
|
// export * from './SignupPage';
|
||||||
export * from './SigninPageExtension';
|
// export * from './SigninPageExtension';
|
||||||
|
@ -25,22 +25,23 @@ export interface IResource {
|
|||||||
export class Auth {
|
export class Auth {
|
||||||
protected api: APIClient;
|
protected api: APIClient;
|
||||||
|
|
||||||
protected NOCOBASE_LOCALE_KEY = 'NOCOBASE_LOCALE';
|
protected KEYS = {
|
||||||
|
locale: 'NOCOBASE_LOCALE',
|
||||||
protected NOCOBASE_ROLE_KEY = 'NOCOBASE_ROLE';
|
role: 'NOCOBASE_ROLE',
|
||||||
|
token: 'NOCOBASE_TOKEN',
|
||||||
|
authenticator: 'NOCOBASE_AUTH',
|
||||||
|
};
|
||||||
|
|
||||||
protected options = {
|
protected options = {
|
||||||
token: null,
|
|
||||||
locale: null,
|
locale: null,
|
||||||
role: null,
|
role: null,
|
||||||
|
authenticator: null,
|
||||||
|
token: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(api: APIClient) {
|
constructor(api: APIClient) {
|
||||||
this.api = api;
|
this.api = api;
|
||||||
this.initKeys();
|
this.initKeys();
|
||||||
this.locale = this.getLocale();
|
|
||||||
this.role = this.getRole();
|
|
||||||
this.token = this.getToken();
|
|
||||||
this.api.axios.interceptors.request.use(this.middleware.bind(this));
|
this.api.axios.interceptors.request.use(this.middleware.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,34 +54,89 @@ export class Auth {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const appName = match[1];
|
const appName = match[1];
|
||||||
this.NOCOBASE_LOCALE_KEY = `${appName.toUpperCase()}_NOCOBASE_LOCALE`;
|
this.KEYS['role'] = `${appName.toUpperCase()}_` + this.KEYS['role'];
|
||||||
this.NOCOBASE_ROLE_KEY = `${appName.toUpperCase()}_NOCOBASE_ROLE`;
|
this.KEYS['locale'] = `${appName.toUpperCase()}_` + this.KEYS['locale'];
|
||||||
}
|
}
|
||||||
|
|
||||||
get locale() {
|
get locale() {
|
||||||
return this.getLocale();
|
return this.getLocale();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
set locale(value: string) {
|
||||||
|
this.setLocale(value);
|
||||||
|
}
|
||||||
|
|
||||||
get role() {
|
get role() {
|
||||||
return this.getRole();
|
return this.getRole();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
set role(value: string) {
|
||||||
|
this.setRole(value);
|
||||||
|
}
|
||||||
|
|
||||||
get token() {
|
get token() {
|
||||||
return this.getToken();
|
return this.getToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
set locale(value) {
|
set token(value: string) {
|
||||||
this.setLocale(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
set role(value) {
|
|
||||||
this.setRole(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
set token(value) {
|
|
||||||
this.setToken(value);
|
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) {
|
middleware(config: AxiosRequestConfig) {
|
||||||
if (this.locale) {
|
if (this.locale) {
|
||||||
config.headers['X-Locale'] = this.locale;
|
config.headers['X-Locale'] = this.locale;
|
||||||
@ -88,63 +144,50 @@ export class Auth {
|
|||||||
if (this.role) {
|
if (this.role) {
|
||||||
config.headers['X-Role'] = this.role;
|
config.headers['X-Role'] = this.role;
|
||||||
}
|
}
|
||||||
|
if (this.authenticator && !config.headers['X-Authenticator']) {
|
||||||
|
config.headers['X-Authenticator'] = this.authenticator;
|
||||||
|
}
|
||||||
if (this.token) {
|
if (this.token) {
|
||||||
config.headers['Authorization'] = `Bearer ${this.token}`;
|
config.headers['Authorization'] = `Bearer ${this.token}`;
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
getLocale() {
|
async signIn(values: any, authenticator?: string): Promise<AxiosResponse<any>> {
|
||||||
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<AxiosResponse<any>> {
|
|
||||||
const response = await this.api.request({
|
const response = await this.api.request({
|
||||||
method: 'post',
|
method: 'post',
|
||||||
url: 'users:signin',
|
url: 'auth:signIn',
|
||||||
data: values,
|
data: values,
|
||||||
params: {
|
headers: {
|
||||||
authenticator,
|
'X-Authenticator': authenticator,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const data = response?.data?.data;
|
const data = response?.data?.data;
|
||||||
this.setToken(data?.token);
|
this.setToken(data?.token);
|
||||||
|
this.setAuthenticator(authenticator);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
async signOut() {
|
async signUp(values: any, authenticator?: string): Promise<AxiosResponse<any>> {
|
||||||
await this.api.request({
|
return await this.api.request({
|
||||||
method: 'post',
|
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.setToken(null);
|
||||||
|
this.setRole(null);
|
||||||
|
this.setAuthenticator(null);
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,13 +24,16 @@ describe('api-client', () => {
|
|||||||
baseURL: 'https://localhost:8000/api',
|
baseURL: 'https://localhost:8000/api',
|
||||||
});
|
});
|
||||||
const mock = new MockAdapter(api.axios);
|
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' },
|
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(response.status).toBe(200);
|
||||||
expect(api.auth.token).toBe('123');
|
expect(api.auth.getToken()).toBe('123');
|
||||||
expect(localStorage.getItem('NOCOBASE_TOKEN')).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 () => {
|
test('resource action', async () => {
|
||||||
@ -72,14 +75,15 @@ describe('api-client', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const mock = new MockAdapter(api.axios);
|
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' },
|
data: { id: 1, name: 'John Smith', token: '123' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await api.auth.signIn({});
|
const response = await api.auth.signIn({});
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(api.auth.token).toBe('123');
|
expect(api.auth.getToken()).toBe('123');
|
||||||
expect(items.get('NOCOBASE_TOKEN')).toBe('123');
|
const token = items.get('NOCOBASE_TOKEN');
|
||||||
|
expect(token).toBe('123');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('custom auth', async () => {
|
test('custom auth', async () => {
|
||||||
@ -87,10 +91,11 @@ describe('api-client', () => {
|
|||||||
async signIn(values: any): Promise<AxiosResponse<any, any>> {
|
async signIn(values: any): Promise<AxiosResponse<any, any>> {
|
||||||
const response = await this.api.request({
|
const response = await this.api.request({
|
||||||
method: 'post',
|
method: 'post',
|
||||||
url: 'users:test',
|
url: 'auth:test',
|
||||||
data: values,
|
data: values,
|
||||||
});
|
});
|
||||||
const data = response?.data?.data;
|
const data = response?.data?.data;
|
||||||
|
this.setAuthenticator('test');
|
||||||
this.setToken(data?.token);
|
this.setToken(data?.token);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@ -104,13 +109,16 @@ describe('api-client', () => {
|
|||||||
expect(api.auth).toBeInstanceOf(TestAuth);
|
expect(api.auth).toBeInstanceOf(TestAuth);
|
||||||
|
|
||||||
const mock = new MockAdapter(api.axios);
|
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' },
|
data: { id: 1, name: 'John Smith', token: '123' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await api.auth.signIn({});
|
const response = await api.auth.signIn({});
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(api.auth.token).toBe('123');
|
expect(api.auth.getToken()).toBe('123');
|
||||||
expect(localStorage.getItem('NOCOBASE_TOKEN')).toBe('123');
|
const token = localStorage.getItem('NOCOBASE_TOKEN');
|
||||||
|
expect(token).toBe('123');
|
||||||
|
const auth = localStorage.getItem('NOCOBASE_AUTH');
|
||||||
|
expect(auth).toBe('test');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
"@koa/router": "^9.4.0",
|
"@koa/router": "^9.4.0",
|
||||||
"@nocobase/acl": "0.9.4-alpha.2",
|
"@nocobase/acl": "0.9.4-alpha.2",
|
||||||
"@nocobase/actions": "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/database": "0.9.4-alpha.2",
|
||||||
"@nocobase/logger": "0.9.4-alpha.2",
|
"@nocobase/logger": "0.9.4-alpha.2",
|
||||||
"@nocobase/resourcer": "0.9.4-alpha.2",
|
"@nocobase/resourcer": "0.9.4-alpha.2",
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { ACL } from '@nocobase/acl';
|
import { ACL } from '@nocobase/acl';
|
||||||
import { registerActions } from '@nocobase/actions';
|
import { registerActions } from '@nocobase/actions';
|
||||||
|
import { actions as authActions, AuthManager } from '@nocobase/auth';
|
||||||
import { Cache, createCache, ICacheConfig } from '@nocobase/cache';
|
import { Cache, createCache, ICacheConfig } from '@nocobase/cache';
|
||||||
import Database, { Collection, CollectionOptions, IDatabaseOptions } from '@nocobase/database';
|
import Database, { Collection, CollectionOptions, IDatabaseOptions } from '@nocobase/database';
|
||||||
import { AppLoggerOptions, createAppLogger, Logger } from '@nocobase/logger';
|
import { AppLoggerOptions, createAppLogger, Logger } from '@nocobase/logger';
|
||||||
@ -164,6 +165,8 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
|
|
||||||
protected _appManager: AppManager;
|
protected _appManager: AppManager;
|
||||||
|
|
||||||
|
protected _authManager: AuthManager;
|
||||||
|
|
||||||
protected _version: ApplicationVersion;
|
protected _version: ApplicationVersion;
|
||||||
|
|
||||||
protected plugins = new Map<string, Plugin>();
|
protected plugins = new Map<string, Plugin>();
|
||||||
@ -215,6 +218,10 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
return this._appManager;
|
return this._appManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get authManager() {
|
||||||
|
return this._authManager;
|
||||||
|
}
|
||||||
|
|
||||||
get logger() {
|
get logger() {
|
||||||
return this._logger;
|
return this._logger;
|
||||||
}
|
}
|
||||||
@ -277,8 +284,18 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
this._appManager = new AppManager(this);
|
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) {
|
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);
|
registerMiddlewares(this, options);
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { Database, mockDatabase } from '@nocobase/database';
|
import { Database, mockDatabase } from '@nocobase/database';
|
||||||
import Application, { ApplicationOptions, PluginManager } from '@nocobase/server';
|
import Application, { ApplicationOptions, PluginManager } from '@nocobase/server';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
import qs from 'qs';
|
import qs from 'qs';
|
||||||
import supertest, { SuperAgentTest } from 'supertest';
|
import supertest, { SuperAgentTest } from 'supertest';
|
||||||
|
|
||||||
@ -80,11 +81,30 @@ export class MockServer extends Application {
|
|||||||
await this.db.clean({ drop: true });
|
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 agent = supertest.agent(this.appManager.callback());
|
||||||
const prefix = this.resourcer.options.prefix;
|
const prefix = this.resourcer.options.prefix;
|
||||||
const proxy = new Proxy(agent, {
|
const proxy = new Proxy(agent, {
|
||||||
get(target, method: string, receiver) {
|
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') {
|
if (method === 'resource') {
|
||||||
return (name: string, resourceOf?: any) => {
|
return (name: string, resourceOf?: any) => {
|
||||||
const keys = name.split('.');
|
const keys = name.split('.');
|
||||||
|
@ -32,15 +32,7 @@ describe('acl', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
userPlugin = app.getPlugin('users') as UsersPlugin;
|
adminAgent = app.agent().login(admin);
|
||||||
|
|
||||||
adminAgent = app.agent().auth(
|
|
||||||
userPlugin.jwtService.sign({
|
|
||||||
userId: admin.get('id'),
|
|
||||||
}),
|
|
||||||
{ type: 'bearer' },
|
|
||||||
);
|
|
||||||
|
|
||||||
uiSchemaRepository = db.getRepository('uiSchemas');
|
uiSchemaRepository = db.getRepository('uiSchemas');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -248,12 +240,7 @@ describe('acl', () => {
|
|||||||
});
|
});
|
||||||
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
||||||
|
|
||||||
const adminAgent = app.agent().auth(
|
const adminAgent = app.agent().login(rootUser);
|
||||||
userPlugin.jwtService.sign({
|
|
||||||
userId: rootUser.get('id'),
|
|
||||||
}),
|
|
||||||
{ type: 'bearer' },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(await db.getCollection('roles').repository.count()).toBe(3);
|
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().login(user);
|
||||||
const userAgent = app.agent().auth(
|
|
||||||
userPlugin.jwtService.sign({
|
|
||||||
userId: user.get('id'),
|
|
||||||
}),
|
|
||||||
{ type: 'bearer' },
|
|
||||||
);
|
|
||||||
|
|
||||||
const schema = {
|
const schema = {
|
||||||
'x-uid': 'test',
|
'x-uid': 'test',
|
||||||
@ -823,21 +804,11 @@ describe('acl', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should destroy new role when user are root user', async () => {
|
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({
|
const rootUser = await db.getRepository('users').findOne({
|
||||||
filterByTk: 1,
|
filterByTk: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
const rootAgent = app.agent().login(rootUser);
|
||||||
|
|
||||||
const rootAgent = app.agent().auth(
|
|
||||||
userPlugin.jwtService.sign({
|
|
||||||
userId: rootUser.get('id'),
|
|
||||||
}),
|
|
||||||
{ type: 'bearer' },
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = await rootAgent
|
const response = await rootAgent
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
@ -89,14 +89,7 @@ describe('association test', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
const userAgent = app.agent().login(user);
|
||||||
|
|
||||||
const userAgent = app.agent().auth(
|
|
||||||
userPlugin.jwtService.sign({
|
|
||||||
userId: user.get('id'),
|
|
||||||
}),
|
|
||||||
{ type: 'bearer' },
|
|
||||||
);
|
|
||||||
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
const response = await userAgent.resource('posts').list({});
|
const response = await userAgent.resource('posts').list({});
|
||||||
@ -153,19 +146,9 @@ describe('association field acl', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
||||||
userAgent = app.agent().auth(
|
userAgent = app.agent().login(user);
|
||||||
userPlugin.jwtService.sign({
|
|
||||||
userId: user.get('id'),
|
|
||||||
}),
|
|
||||||
{ type: 'bearer' },
|
|
||||||
);
|
|
||||||
|
|
||||||
adminAgent = app.agent().auth(
|
adminAgent = app.agent().login(admin);
|
||||||
userPlugin.jwtService.sign({
|
|
||||||
userId: admin.get('id'),
|
|
||||||
}),
|
|
||||||
{ type: 'bearer' },
|
|
||||||
);
|
|
||||||
|
|
||||||
await db.getRepository('collections').create({
|
await db.getRepository('collections').create({
|
||||||
values: {
|
values: {
|
||||||
|
@ -46,19 +46,9 @@ describe('configuration', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
||||||
adminAgent = app.agent().auth(
|
adminAgent = app.agent().login(admin);
|
||||||
userPlugin.jwtService.sign({
|
|
||||||
userId: admin.get('id'),
|
|
||||||
}),
|
|
||||||
{ type: 'bearer' },
|
|
||||||
);
|
|
||||||
|
|
||||||
userAgent = app.agent().auth(
|
userAgent = app.agent().login(user);
|
||||||
userPlugin.jwtService.sign({
|
|
||||||
userId: user.get('id'),
|
|
||||||
}),
|
|
||||||
{ type: 'bearer' },
|
|
||||||
);
|
|
||||||
|
|
||||||
guestAgent = app.agent();
|
guestAgent = app.agent();
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Database } from '@nocobase/database';
|
import { Database } from '@nocobase/database';
|
||||||
import { prepareApp } from './prepare';
|
|
||||||
import { MockServer } from '@nocobase/test';
|
import { MockServer } from '@nocobase/test';
|
||||||
|
import { prepareApp } from './prepare';
|
||||||
|
|
||||||
describe('list action with acl', () => {
|
describe('list action with acl', () => {
|
||||||
let app: MockServer;
|
let app: MockServer;
|
||||||
@ -302,15 +302,7 @@ describe('list association action with acl', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const userPlugin = app.getPlugin('users');
|
const userPlugin = app.getPlugin('users');
|
||||||
const userAgent = app
|
const userAgent = app.agent().login(user).set('X-With-ACL-Meta', true);
|
||||||
.agent()
|
|
||||||
.set('X-With-ACL-Meta', true)
|
|
||||||
.auth(
|
|
||||||
userPlugin.jwtService.sign({
|
|
||||||
userId: user.get('id'),
|
|
||||||
}),
|
|
||||||
{ type: 'bearer' },
|
|
||||||
);
|
|
||||||
|
|
||||||
await userAgent.resource('posts').create({
|
await userAgent.resource('posts').create({
|
||||||
values: {
|
values: {
|
||||||
@ -349,16 +341,7 @@ describe('list association action with acl', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const userPlugin = app.getPlugin('users');
|
const userPlugin = app.getPlugin('users');
|
||||||
const agent = app
|
const agent = app.agent().login(user).set('X-With-ACL-Meta', true);
|
||||||
.agent()
|
|
||||||
.set('X-With-ACL-Meta', true)
|
|
||||||
.auth(
|
|
||||||
userPlugin.jwtService.sign({
|
|
||||||
userId: user.get('id'),
|
|
||||||
}),
|
|
||||||
{ type: 'bearer' },
|
|
||||||
);
|
|
||||||
|
|
||||||
app.acl.allow('table_a', ['*']);
|
app.acl.allow('table_a', ['*']);
|
||||||
app.acl.allow('collections', ['*']);
|
app.acl.allow('collections', ['*']);
|
||||||
|
|
||||||
|
@ -31,12 +31,7 @@ describe('middleware', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
||||||
adminAgent = app.agent().auth(
|
adminAgent = app.agent().login(admin);
|
||||||
userPlugin.jwtService.sign({
|
|
||||||
userId: admin.get('id'),
|
|
||||||
}),
|
|
||||||
{ type: 'bearer' },
|
|
||||||
);
|
|
||||||
|
|
||||||
await db.getRepository('collections').create({
|
await db.getRepository('collections').create({
|
||||||
values: {
|
values: {
|
||||||
|
@ -2,7 +2,6 @@ import { ACL } from '@nocobase/acl';
|
|||||||
import { Database } from '@nocobase/database';
|
import { Database } from '@nocobase/database';
|
||||||
import PluginUser from '@nocobase/plugin-users';
|
import PluginUser from '@nocobase/plugin-users';
|
||||||
import { MockServer } from '@nocobase/test';
|
import { MockServer } from '@nocobase/test';
|
||||||
|
|
||||||
import { prepareApp } from './prepare';
|
import { prepareApp } from './prepare';
|
||||||
|
|
||||||
describe('own test', () => {
|
describe('own test', () => {
|
||||||
@ -69,9 +68,7 @@ describe('own test', () => {
|
|||||||
|
|
||||||
pluginUser = app.getPlugin('users');
|
pluginUser = app.getPlugin('users');
|
||||||
|
|
||||||
adminToken = pluginUser.jwtService.sign({ userId: admin.get('id') });
|
adminAgent = app.agent().login(admin);
|
||||||
|
|
||||||
adminAgent = app.agent().auth(adminToken, { type: 'bearer' });
|
|
||||||
|
|
||||||
user = await db.getRepository('users').create({
|
user = await db.getRepository('users').create({
|
||||||
values: {
|
values: {
|
||||||
@ -80,9 +77,7 @@ describe('own test', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
userToken = pluginUser.jwtService.sign({ userId: user.get('id') });
|
userAgent = app.agent().login(user);
|
||||||
|
|
||||||
userAgent = app.agent().auth(userToken, { type: 'bearer' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should list without createBy', async () => {
|
it('should list without createBy', async () => {
|
||||||
|
@ -5,7 +5,7 @@ export async function prepareApp(): Promise<MockServer> {
|
|||||||
const app = mockServer({
|
const app = mockServer({
|
||||||
registerActions: true,
|
registerActions: true,
|
||||||
acl: 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, {
|
app.plugin(PluginACL, {
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { Database } from '@nocobase/database';
|
import { Database } from '@nocobase/database';
|
||||||
import UsersPlugin from '@nocobase/plugin-users';
|
import UsersPlugin from '@nocobase/plugin-users';
|
||||||
import { MockServer } from '@nocobase/test';
|
import { MockServer } from '@nocobase/test';
|
||||||
|
|
||||||
import { prepareApp } from './prepare';
|
import { prepareApp } from './prepare';
|
||||||
|
|
||||||
describe('role check action', () => {
|
describe('role check action', () => {
|
||||||
@ -37,12 +36,7 @@ describe('role check action', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
||||||
const agent = app.agent().auth(
|
const agent = app.agent().login(user);
|
||||||
userPlugin.jwtService.sign({
|
|
||||||
userId: user.get('id'),
|
|
||||||
}),
|
|
||||||
{ type: 'bearer' },
|
|
||||||
);
|
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const response = await agent.resource('roles').check();
|
const response = await agent.resource('roles').check();
|
||||||
|
@ -33,12 +33,7 @@ describe('role resource api', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
||||||
adminAgent = app.agent().auth(
|
adminAgent = app.agent().login(admin);
|
||||||
userPlugin.jwtService.sign({
|
|
||||||
userId: admin.get('id'),
|
|
||||||
}),
|
|
||||||
{ type: 'bearer' },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should grant resource by createRepository', async () => {
|
it('should grant resource by createRepository', async () => {
|
||||||
|
@ -2,6 +2,8 @@ import Database, { BelongsToManyRepository } from '@nocobase/database';
|
|||||||
import PluginACL from '@nocobase/plugin-acl';
|
import PluginACL from '@nocobase/plugin-acl';
|
||||||
import UsersPlugin from '@nocobase/plugin-users';
|
import UsersPlugin from '@nocobase/plugin-users';
|
||||||
import { MockServer, mockServer } from '@nocobase/test';
|
import { MockServer, mockServer } from '@nocobase/test';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import AuthPlugin from '@nocobase/plugin-auth';
|
||||||
|
|
||||||
describe('role', () => {
|
describe('role', () => {
|
||||||
let api: MockServer;
|
let api: MockServer;
|
||||||
@ -14,6 +16,7 @@ describe('role', () => {
|
|||||||
await api.cleanDb();
|
await api.cleanDb();
|
||||||
api.plugin(UsersPlugin, { name: 'users' });
|
api.plugin(UsersPlugin, { name: 'users' });
|
||||||
api.plugin(PluginACL, { name: 'acl' });
|
api.plugin(PluginACL, { name: 'acl' });
|
||||||
|
api.plugin(AuthPlugin, { name: 'auth' });
|
||||||
await api.loadAndInstall();
|
await api.loadAndInstall();
|
||||||
|
|
||||||
db = api.db;
|
db = api.db;
|
||||||
@ -102,7 +105,7 @@ describe('role', () => {
|
|||||||
await userRolesRepo.add('test1');
|
await userRolesRepo.add('test1');
|
||||||
await userRolesRepo.add('test2');
|
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
|
const response = await api
|
||||||
.agent()
|
.agent()
|
||||||
.post('/users:setDefaultRole')
|
.post('/users:setDefaultRole')
|
||||||
@ -111,6 +114,7 @@ describe('role', () => {
|
|||||||
})
|
})
|
||||||
.set({
|
.set({
|
||||||
Authorization: `Bearer ${userToken}`,
|
Authorization: `Bearer ${userToken}`,
|
||||||
|
'X-Authenticator': 'basic',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toEqual(200);
|
expect(response.statusCode).toEqual(200);
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { ArrayFieldRepository, Database, Model } from '@nocobase/database';
|
import { ArrayFieldRepository, Database, Model } from '@nocobase/database';
|
||||||
import UsersPlugin from '@nocobase/plugin-users';
|
import UsersPlugin from '@nocobase/plugin-users';
|
||||||
import { MockServer } from '@nocobase/test';
|
import { MockServer } from '@nocobase/test';
|
||||||
|
|
||||||
import { prepareApp } from './prepare';
|
import { prepareApp } from './prepare';
|
||||||
|
|
||||||
describe('role api', () => {
|
describe('role api', () => {
|
||||||
@ -37,12 +36,7 @@ describe('role api', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
||||||
adminAgent = app.agent().auth(
|
adminAgent = app.agent().login(admin);
|
||||||
userPlugin.jwtService.sign({
|
|
||||||
userId: admin.get('id'),
|
|
||||||
}),
|
|
||||||
{ type: 'bearer' },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should list actions', async () => {
|
it('should list actions', async () => {
|
||||||
|
@ -26,12 +26,7 @@ describe('scope api', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
||||||
adminAgent = app.agent().auth(
|
adminAgent = app.agent().login(admin);
|
||||||
userPlugin.jwtService.sign({
|
|
||||||
userId: admin.get('id'),
|
|
||||||
}),
|
|
||||||
{ type: 'bearer' },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create scope of resource', async () => {
|
it('should create scope of resource', async () => {
|
||||||
|
@ -27,13 +27,7 @@ describe('snippet', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const userPlugin: any = app.getPlugin('users');
|
const userPlugin: any = app.getPlugin('users');
|
||||||
const userAgent: any = app.agent().auth(
|
const userAgent: any = app.agent().login(user);
|
||||||
userPlugin.jwtService.sign({
|
|
||||||
userId: user.get('id'),
|
|
||||||
}),
|
|
||||||
{ type: 'bearer' },
|
|
||||||
);
|
|
||||||
|
|
||||||
const createCollectionResponse = await userAgent.resource('collections').create({});
|
const createCollectionResponse = await userAgent.resource('collections').create({});
|
||||||
|
|
||||||
expect(createCollectionResponse.statusCode).toEqual(403);
|
expect(createCollectionResponse.statusCode).toEqual(403);
|
||||||
|
@ -27,12 +27,7 @@ describe('actions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
agent = app.agent();
|
agent = app.agent();
|
||||||
adminAgent = app.agent().auth(
|
adminAgent = app.agent().login(adminUser);
|
||||||
pluginUser.jwtService.sign({
|
|
||||||
userId: adminUser.get('id'),
|
|
||||||
}),
|
|
||||||
{ type: 'bearer' },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
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',
|
email: 'test2@nocobase.com',
|
||||||
password: '123456',
|
password: '123456',
|
||||||
});
|
});
|
||||||
@ -89,7 +84,7 @@ describe('actions', () => {
|
|||||||
filterByTk: 'test',
|
filterByTk: 'test',
|
||||||
});
|
});
|
||||||
|
|
||||||
response = await agent.post('/users:signin').send({
|
response = await agent.post('/auth:signIn').send({
|
||||||
email: 'test2@nocobase.com',
|
email: 'test2@nocobase.com',
|
||||||
password: '123456',
|
password: '123456',
|
||||||
});
|
});
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { MockServer } from '@nocobase/test';
|
import { MockServer } from '@nocobase/test';
|
||||||
import { Database } from '@nocobase/database';
|
import { Database } from '@nocobase/database';
|
||||||
import { prepareApp } from './prepare';
|
import { prepareApp } from './prepare';
|
||||||
import UsersPlugin from '@nocobase/plugin-users';
|
|
||||||
|
|
||||||
describe('write role to acl', () => {
|
describe('write role to acl', () => {
|
||||||
let app: MockServer;
|
let app: MockServer;
|
||||||
@ -32,13 +31,7 @@ describe('write role to acl', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
const agent = app.agent().login(user);
|
||||||
const agent = app.agent().auth(
|
|
||||||
userPlugin.jwtService.sign({
|
|
||||||
userId: user.get('id'),
|
|
||||||
}),
|
|
||||||
{ type: 'bearer' },
|
|
||||||
);
|
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const response = await agent.resource('roles').check();
|
const response = await agent.resource('roles').check();
|
||||||
|
@ -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('users', 'setDefaultRole', 'loggedIn');
|
||||||
this.app.acl.allow('roles', 'check', 'loggedIn');
|
this.app.acl.allow('roles', 'check', 'loggedIn');
|
||||||
|
117
packages/plugins/auth/README.md
Normal file
117
packages/plugins/auth/README.md
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
# Auth
|
||||||
|
|
||||||
|
提供基础认证功能和扩展认证器管理功能。
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 认证器管理
|
||||||
|
页面:系统设置 - 认证
|
||||||
|
|
||||||
|
|
||||||
|
<img src="https://s2.loli.net/2023/05/15/NdriQZvBuE6hGRS.png" width="800px" />
|
||||||
|
|
||||||
|
#### 内置认证器
|
||||||
|
- 名称:basic
|
||||||
|
- 认证类型:邮箱密码登录
|
||||||
|
|
||||||
|
<img src="https://s2.loli.net/2023/05/15/HC4gtx9fQPrqvac.png" width="300px" />
|
||||||
|
|
||||||
|
<img src="https://s2.loli.net/2023/05/15/fcLqypdhOxnksbg.png" width="300px" />
|
||||||
|
|
||||||
|
#### 增加认证器
|
||||||
|
Add new - 选择认证类型
|
||||||
|
|
||||||
|
<img src="https://s2.loli.net/2023/05/15/CR7UTDt2WzbEfgs.png" width="300px" />
|
||||||
|
|
||||||
|
#### 启用/禁用
|
||||||
|
Actions - Edit - 勾选/取消Enabled
|
||||||
|
|
||||||
|
<img src="https://s2.loli.net/2023/05/15/2utpSHly9fzCKX5.png" width="400px" />
|
||||||
|
|
||||||
|
#### 配置认证器
|
||||||
|
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
|
||||||
|
<OptionsComponentProvider authType="custom-auth-type" component={Options} />
|
||||||
|
```
|
||||||
|
|
||||||
|
`Options`组件使用的值是`authenticator`的`options`字段,如果有需要暴露在前端的配置,应该放在`options.public`字段中。`authenticators:publicList`接口会返回`options.public`字段的值。
|
||||||
|
|
||||||
|
#### SigninPageProvider
|
||||||
|
自定义登录页界面
|
||||||
|
- authType 认证方式
|
||||||
|
- tabTitle 登录页tab标题
|
||||||
|
- component 登录页组件
|
||||||
|
|
||||||
|
#### SignupPageProvider
|
||||||
|
自定义注册页界面
|
||||||
|
- authType 认证方式
|
||||||
|
- component 注册页组件
|
||||||
|
|
||||||
|
#### SigninPageExtensionProvider
|
||||||
|
自定义登录页下方的扩展内容
|
||||||
|
- authType 认证方式
|
||||||
|
- component 扩展组件
|
3
packages/plugins/auth/client.d.ts
vendored
Executable file
3
packages/plugins/auth/client.d.ts
vendored
Executable file
@ -0,0 +1,3 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
export * from './lib/client';
|
||||||
|
export { default } from './lib/client';
|
65
packages/plugins/auth/client.js
Executable file
65
packages/plugins/auth/client.js
Executable file
@ -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];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
16
packages/plugins/auth/package.json
Normal file
16
packages/plugins/auth/package.json
Normal file
@ -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": "提供基础认证功能和扩展认证器管理功能。"
|
||||||
|
}
|
3
packages/plugins/auth/server.d.ts
vendored
Executable file
3
packages/plugins/auth/server.d.ts
vendored
Executable file
@ -0,0 +1,3 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
export * from './lib/server';
|
||||||
|
export { default } from './lib/server';
|
65
packages/plugins/auth/server.js
Executable file
65
packages/plugins/auth/server.js
Executable file
@ -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];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
31
packages/plugins/auth/src/client/basic/Options.tsx
Normal file
31
packages/plugins/auth/src/client/basic/Options.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { SchemaComponent } from '@nocobase/client';
|
||||||
|
import React from 'react';
|
||||||
|
import { useAuthTranslation } from '../locale';
|
||||||
|
|
||||||
|
export const Options = () => {
|
||||||
|
const { t } = useAuthTranslation();
|
||||||
|
return (
|
||||||
|
<SchemaComponent
|
||||||
|
scope={{ t }}
|
||||||
|
schema={{
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
public: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
allowSignup: {
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
type: 'boolean',
|
||||||
|
title: '{{t("Allow to sign up")}}',
|
||||||
|
'x-component': 'Checkbox',
|
||||||
|
'x-component-props': {
|
||||||
|
defaultChecked: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
65
packages/plugins/auth/src/client/basic/SigninPage.tsx
Normal file
65
packages/plugins/auth/src/client/basic/SigninPage.tsx
Normal file
@ -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 <SchemaComponent schema={passwordForm} scope={{ useBasicSignIn, allowSignUp, signupLink }} />;
|
||||||
|
};
|
91
packages/plugins/auth/src/client/basic/SignupPage.tsx
Normal file
91
packages/plugins/auth/src/client/basic/SignupPage.tsx
Normal file
@ -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 <SchemaComponent schema={signupPageSchema} scope={{ useBasicSignup }} />;
|
||||||
|
};
|
41
packages/plugins/auth/src/client/index.tsx
Normal file
41
packages/plugins/auth/src/client/index.tsx
Normal file
@ -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 (
|
||||||
|
<SettingsCenterProvider
|
||||||
|
settings={{
|
||||||
|
auth: {
|
||||||
|
title: t('Authentication'),
|
||||||
|
icon: 'LoginOutlined',
|
||||||
|
tabs: {
|
||||||
|
authenticators: {
|
||||||
|
title: t('Authenticators'),
|
||||||
|
component: () => <Authenticator />,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<OptionsComponentProvider authType={presetAuthType} component={Options}>
|
||||||
|
<SigninPageProvider authType={presetAuthType} tabTitle={t('Sign in via email')} component={SigninPage}>
|
||||||
|
<SignupPageProvider authType={presetAuthType} component={SignupPage}>
|
||||||
|
{props.children}
|
||||||
|
</SignupPageProvider>
|
||||||
|
</SigninPageProvider>
|
||||||
|
</OptionsComponentProvider>
|
||||||
|
</SettingsCenterProvider>
|
||||||
|
);
|
||||||
|
};
|
7
packages/plugins/auth/src/client/locale/index.ts
Normal file
7
packages/plugins/auth/src/client/locale/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
export const NAMESPACE = 'auth';
|
||||||
|
|
||||||
|
export function useAuthTranslation() {
|
||||||
|
return useTranslation(NAMESPACE);
|
||||||
|
}
|
10
packages/plugins/auth/src/client/locale/zh-CN.ts
Normal file
10
packages/plugins/auth/src/client/locale/zh-CN.ts
Normal file
@ -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;
|
95
packages/plugins/auth/src/client/settings/Authenticator.tsx
Normal file
95
packages/plugins/auth/src/client/settings/Authenticator.tsx
Normal file
@ -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 (
|
||||||
|
<ActionContext.Provider value={{ visible, setVisible }}>
|
||||||
|
<AuthTypeContext.Provider value={{ type }}>
|
||||||
|
<Dropdown menu={{ items }}>
|
||||||
|
<Button icon={<PlusOutlined />} type={'primary'}>
|
||||||
|
{t('Add new')} <DownOutlined />
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
<SchemaComponent scope={{ useCloseAction, types, setType }} schema={createFormSchema} />
|
||||||
|
</AuthTypeContext.Provider>
|
||||||
|
</ActionContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<Card bordered={false}>
|
||||||
|
<AuthTypesContext.Provider value={{ types }}>
|
||||||
|
<SchemaComponent
|
||||||
|
schema={authenticatorsSchema}
|
||||||
|
components={{ AddNew, Options }}
|
||||||
|
scope={{ types, useValuesFromOptions, useCanNotDelete }}
|
||||||
|
/>
|
||||||
|
</AuthTypesContext.Provider>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
35
packages/plugins/auth/src/client/settings/Options.tsx
Normal file
35
packages/plugins/auth/src/client/settings/Options.tsx
Normal file
@ -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;
|
||||||
|
});
|
18
packages/plugins/auth/src/client/settings/authType.ts
Normal file
18
packages/plugins/auth/src/client/settings/authType.ts
Normal file
@ -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;
|
||||||
|
};
|
@ -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() }}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
1
packages/plugins/auth/src/index.ts
Normal file
1
packages/plugins/auth/src/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default, AuthModel } from './server';
|
4
packages/plugins/auth/src/preset.ts
Normal file
4
packages/plugins/auth/src/preset.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export const presetAuthType = 'Email/Password';
|
||||||
|
export const presetAuthenticator = 'basic';
|
||||||
|
|
||||||
|
export const namespace = require('../package.json').name;
|
142
packages/plugins/auth/src/server/__tests__/actions.test.ts
Normal file
142
packages/plugins/auth/src/server/__tests__/actions.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
20
packages/plugins/auth/src/server/actions/auth.ts
Normal file
20
packages/plugins/auth/src/server/actions/auth.ts
Normal file
@ -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();
|
||||||
|
},
|
||||||
|
};
|
85
packages/plugins/auth/src/server/actions/authenticators.ts
Normal file
85
packages/plugins/auth/src/server/actions/authenticators.ts
Normal file
@ -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();
|
||||||
|
},
|
||||||
|
};
|
128
packages/plugins/auth/src/server/basic-auth.ts
Normal file
128
packages/plugins/auth/src/server/basic-auth.ts
Normal file
@ -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<PasswordField>('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<PasswordField>('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;
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
@ -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;
|
2
packages/plugins/auth/src/server/index.ts
Normal file
2
packages/plugins/auth/src/server/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { default } from './plugin';
|
||||||
|
export { AuthModel } from './model/authenticator';
|
10
packages/plugins/auth/src/server/locale/en-US.ts
Normal file
10
packages/plugins/auth/src/server/locale/en-US.ts
Normal file
@ -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',
|
||||||
|
};
|
3
packages/plugins/auth/src/server/locale/index.ts
Normal file
3
packages/plugins/auth/src/server/locale/index.ts
Normal file
@ -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';
|
4
packages/plugins/auth/src/server/locale/ja-JP.ts
Normal file
4
packages/plugins/auth/src/server/locale/ja-JP.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export default {
|
||||||
|
'Please fill in your email address': 'メールアドレスを入力してください',
|
||||||
|
'The password is incorrect, please re-enter': 'パスワードが正しくありません。再度入力してください。',
|
||||||
|
};
|
10
packages/plugins/auth/src/server/locale/pt-BR.ts
Normal file
10
packages/plugins/auth/src/server/locale/pt-BR.ts
Normal file
@ -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',
|
||||||
|
};
|
9
packages/plugins/auth/src/server/locale/zh-CN.ts
Normal file
9
packages/plugins/auth/src/server/locale/zh-CN.ts
Normal file
@ -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': '请至少保留并启用一个认证器',
|
||||||
|
};
|
@ -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() {}
|
||||||
|
}
|
@ -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() {}
|
||||||
|
}
|
48
packages/plugins/auth/src/server/model/authenticator.ts
Normal file
48
packages/plugins/auth/src/server/model/authenticator.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
92
packages/plugins/auth/src/server/plugin.ts
Normal file
92
packages/plugins/auth/src/server/plugin.ts
Normal file
@ -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;
|
@ -2,17 +2,19 @@
|
|||||||
"name": "@nocobase/plugin-oidc",
|
"name": "@nocobase/plugin-oidc",
|
||||||
"displayName": "OpenID Connect",
|
"displayName": "OpenID Connect",
|
||||||
"displayName.zh-CN": "OpenID Connect",
|
"displayName.zh-CN": "OpenID Connect",
|
||||||
"description": " provide OIDC authentication and authorization functionality for applications or authentication systems.",
|
"description": "Provides OIDC authentication and authorization functionality for applications or authentication systems.",
|
||||||
"description.zh-CN": "在为应用程序或身份验证系统提供OIDC认证和授权功能。",
|
"description.zh-CN": "为应用程序或身份验证系统提供OIDC认证和授权功能。",
|
||||||
"version": "0.9.4-alpha.2",
|
"version": "0.9.4-alpha.2",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"main": "./lib/index.js",
|
"main": "./lib/index.js",
|
||||||
"types": "./lib/index.d.ts",
|
"types": "./lib/index.d.ts",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@nocobase/auth": "0.9.4-alpha.2",
|
||||||
"@nocobase/server": "0.9.4-alpha.2",
|
"@nocobase/server": "0.9.4-alpha.2",
|
||||||
"@nocobase/test": "0.9.4-alpha.2"
|
"@nocobase/test": "0.9.4-alpha.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"openid-client": "^5.3.0"
|
"nanoid": "3.3.4",
|
||||||
|
"openid-client": "^5.4.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,6 +1,6 @@
|
|||||||
import { LoginOutlined } from '@ant-design/icons';
|
import { LoginOutlined } from '@ant-design/icons';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { useAPIClient, useRedirect, useRequest } from '@nocobase/client';
|
import { Authenticator, useAPIClient, useRedirect } from '@nocobase/client';
|
||||||
import { useMemoizedFn } from 'ahooks';
|
import { useMemoizedFn } from 'ahooks';
|
||||||
import { Button, Space } from 'antd';
|
import { Button, Space } from 'antd';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
@ -10,25 +10,20 @@ export interface OIDCProvider {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OIDCList = () => {
|
export const OIDCButton = (props: { authenticator: Authenticator }) => {
|
||||||
const [windowHandler, setWindowHandler] = useState<Window | undefined>();
|
const [windowHandler, setWindowHandler] = useState<Window | undefined>();
|
||||||
const api = useAPIClient();
|
const api = useAPIClient();
|
||||||
const redirect = useRedirect();
|
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({
|
const response = await api.request({
|
||||||
method: 'post',
|
method: 'post',
|
||||||
url: 'oidc:getAuthUrl',
|
url: 'oidc:getAuthUrl',
|
||||||
data: {
|
headers: {
|
||||||
clientId: item.clientId,
|
'X-Authenticator': name,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -44,16 +39,24 @@ export const OIDCList = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
setWindowHandler(win);
|
setWindowHandler(win);
|
||||||
});
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从弹出窗口,发消息回来进行登录
|
* 从弹出窗口,发消息回来进行登录
|
||||||
*/
|
*/
|
||||||
const handleOIDCLogin = useMemoizedFn(async (event: MessageEvent) => {
|
const handleOIDCLogin = useMemoizedFn(async (event: MessageEvent) => {
|
||||||
await api.auth.signIn(event.data, 'oidc');
|
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();
|
windowHandler.close();
|
||||||
setWindowHandler(undefined);
|
setWindowHandler(undefined);
|
||||||
redirect();
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -61,12 +64,14 @@ export const OIDCList = () => {
|
|||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!windowHandler) return;
|
if (!windowHandler) return;
|
||||||
|
|
||||||
window.addEventListener('message', handleOIDCLogin);
|
window.addEventListener('message', handleOIDCLogin);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('message', handleOIDCLogin);
|
window.removeEventListener('message', handleOIDCLogin);
|
||||||
};
|
};
|
||||||
}, [windowHandler]);
|
}, [windowHandler, handleOIDCLogin]);
|
||||||
|
|
||||||
|
const authenticator = props.authenticator;
|
||||||
return (
|
return (
|
||||||
<Space
|
<Space
|
||||||
direction="vertical"
|
direction="vertical"
|
||||||
@ -74,11 +79,9 @@ export const OIDCList = () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{data?.data?.map?.((item: OIDCProvider) => (
|
<Button shape="round" block icon={<LoginOutlined />} onClick={() => handleOpen(authenticator.name)}>
|
||||||
<Button shape="round" block key={item.clientId} icon={<LoginOutlined />} onClick={() => handleOpen(item)}>
|
{authenticator.title}
|
||||||
{item.title}
|
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
};
|
};
|
@ -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 (
|
|
||||||
<Card bordered={false}>
|
|
||||||
<SchemaComponent components={{ RedirectURLInput }} schema={schema} scope={{ t }} />
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
136
packages/plugins/oidc/src/client/Options.tsx
Normal file
136
packages/plugins/oidc/src/client/Options.tsx
Normal file
@ -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 (
|
||||||
|
<Card title={t('Usage')} type="inner">
|
||||||
|
<FormItem label={t('Redirect URL')}>
|
||||||
|
<Input value={url} disabled={true} addonBefore={<CopyOutlined onClick={() => copy(url)} />} />
|
||||||
|
</FormItem>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Options = () => {
|
||||||
|
const { t } = useOidcTranslation();
|
||||||
|
return <SchemaComponent scope={{ t }} components={{ Usage, ArrayItems }} schema={schema} />;
|
||||||
|
};
|
@ -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<Field>();
|
|
||||||
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 (
|
|
||||||
<div>
|
|
||||||
<FormLayout layout={'vertical'}>
|
|
||||||
<Input disabled value={field.value} />
|
|
||||||
</FormLayout>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
{ displayName: 'RedirectURLInput' },
|
|
||||||
);
|
|
@ -1,38 +1,15 @@
|
|||||||
import { PluginManagerContext, SettingsCenterProvider, SigninPageExtensionProvider } from '@nocobase/client';
|
import { OptionsComponentProvider, SigninPageExtensionProvider } from '@nocobase/client';
|
||||||
import React, { useContext } from 'react';
|
import React from 'react';
|
||||||
import { useOidcTranslation } from './locale';
|
import { OIDCButton } from './OIDCButton';
|
||||||
import { OIDCList } from './OIDCList';
|
import { authType } from '../constants';
|
||||||
import { OIDCPanel } from './OIDCPanel';
|
import { Options } from './Options';
|
||||||
|
|
||||||
export default function (props) {
|
export default function (props) {
|
||||||
const { t } = useOidcTranslation();
|
|
||||||
const ctx = useContext(PluginManagerContext);
|
|
||||||
return (
|
return (
|
||||||
<SigninPageExtensionProvider component={OIDCList}>
|
<SigninPageExtensionProvider component={OIDCButton} authType={authType}>
|
||||||
<SettingsCenterProvider
|
<OptionsComponentProvider authType={authType} component={Options}>
|
||||||
settings={{
|
|
||||||
oidc: {
|
|
||||||
title: t('OIDC manager'),
|
|
||||||
icon: 'FileOutlined',
|
|
||||||
tabs: {
|
|
||||||
providers: {
|
|
||||||
title: t('OIDC Providers'),
|
|
||||||
component: OIDCPanel,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PluginManagerContext.Provider
|
|
||||||
value={{
|
|
||||||
components: {
|
|
||||||
...ctx?.components,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.children}
|
{props.children}
|
||||||
</PluginManagerContext.Provider>
|
</OptionsComponentProvider>
|
||||||
</SettingsCenterProvider>
|
|
||||||
</SigninPageExtensionProvider>
|
</SigninPageExtensionProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,25 +1,8 @@
|
|||||||
export default {
|
export default {
|
||||||
Issuer: 'Issuer',
|
|
||||||
Enable: '启用',
|
Enable: '启用',
|
||||||
Actions: '操作',
|
Actions: '操作',
|
||||||
Delete: '删除',
|
Delete: '删除',
|
||||||
Edit: '编辑',
|
Edit: '编辑',
|
||||||
'Button title': '登录按钮标题',
|
Copied: '已复制',
|
||||||
'OIDC manager': 'OIDC 管理',
|
'Field Map': '字段映射',
|
||||||
'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': '登录按钮名称,将在登录页中显示',
|
|
||||||
};
|
};
|
||||||
|
@ -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}}',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
3
packages/plugins/oidc/src/constants.ts
Normal file
3
packages/plugins/oidc/src/constants.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const authType = 'OIDC';
|
||||||
|
export const cookieName = 'nocobase_oidc';
|
||||||
|
export const namespace = require('../package.json').name;
|
@ -1,3 +1 @@
|
|||||||
export { default } from './server';
|
export { default } from './server';
|
||||||
|
|
||||||
export const namespace = require('../package.json').name;
|
|
||||||
|
115
packages/plugins/oidc/src/server/__tests__/oidc.test.ts
Normal file
115
packages/plugins/oidc/src/server/__tests__/oidc.test.ts
Normal file
@ -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',
|
||||||
|
});
|
||||||
|
});
|
@ -1,23 +1,22 @@
|
|||||||
import { Context } from '@nocobase/actions';
|
import { Context, Next } from '@nocobase/actions';
|
||||||
import { createOIDCClient } from '../shared/createOIDCClient';
|
import { OIDCAuth } from '../oidc-auth';
|
||||||
import { OIDCProvider } from '../shared/types';
|
import { nanoid } from 'nanoid';
|
||||||
|
import { cookieName } from '../../constants';
|
||||||
|
|
||||||
export const getAuthUrl = async (ctx: Context, next) => {
|
export const getAuthUrl = async (ctx: Context, next: Next) => {
|
||||||
const {
|
const auth = ctx.auth as OIDCAuth;
|
||||||
params: { values },
|
const client = await auth.createOIDCClient();
|
||||||
} = ctx.action;
|
const { scope } = auth.getOptions();
|
||||||
const providerRepo = ctx.db.getRepository('oidcProviders');
|
const token = nanoid(15);
|
||||||
const record = await providerRepo.findOne({
|
ctx.cookies.set(cookieName, token, {
|
||||||
filter: {
|
httpOnly: true,
|
||||||
clientId: values.clientId,
|
domain: ctx.hostname,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const provider: OIDCProvider = record.toJSON();
|
|
||||||
const client = await createOIDCClient(provider);
|
|
||||||
|
|
||||||
ctx.body = client.authorizationUrl({
|
ctx.body = client.authorizationUrl({
|
||||||
nonce: ctx.OIDC_NONCE,
|
response_type: 'code',
|
||||||
scope: 'openid profile',
|
scope: scope || 'openid email profile',
|
||||||
|
redirect_uri: auth.getRedirectUri(),
|
||||||
|
state: `token=${token}&name=${ctx.headers['x-authenticator']}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
|
@ -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();
|
|
||||||
};
|
|
@ -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();
|
|
||||||
};
|
|
@ -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;
|
|
91
packages/plugins/oidc/src/server/oidc-auth.ts
Normal file
91
packages/plugins/oidc/src/server/oidc-auth.ts
Normal file
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -1,27 +1,18 @@
|
|||||||
import { InstallOptions, Plugin } from '@nocobase/server';
|
import { InstallOptions, Plugin } from '@nocobase/server';
|
||||||
import { generators } from 'openid-client';
|
|
||||||
import { resolve } from 'path';
|
|
||||||
import { getAuthUrl } from './actions/getAuthUrl';
|
import { getAuthUrl } from './actions/getAuthUrl';
|
||||||
import { getEnabledProviders } from './actions/getEnabledProviders';
|
|
||||||
import { redirect } from './actions/redirect';
|
import { redirect } from './actions/redirect';
|
||||||
import { oidc } from './authenticators/oidc';
|
import { authType } from '../constants';
|
||||||
|
import { OIDCAuth } from './oidc-auth';
|
||||||
|
|
||||||
export class OidcPlugin extends Plugin {
|
export class OidcPlugin extends Plugin {
|
||||||
#OIDC_NONCE = null;
|
|
||||||
|
|
||||||
afterAdd() {}
|
afterAdd() {}
|
||||||
|
|
||||||
beforeLoad() {}
|
beforeLoad() {}
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
// 导入 collection
|
this.app.authManager.registerTypes(authType, {
|
||||||
await this.importCollections(resolve(__dirname, './collections'));
|
auth: OIDCAuth,
|
||||||
|
});
|
||||||
// 获取 User 插件
|
|
||||||
const userPlugin = this.app.getPlugin<any>('users');
|
|
||||||
|
|
||||||
// 注册 OIDC 验证器
|
|
||||||
userPlugin.authenticators.register('oidc', oidc);
|
|
||||||
|
|
||||||
// 注册接口
|
// 注册接口
|
||||||
this.app.resource({
|
this.app.resource({
|
||||||
@ -29,27 +20,10 @@ export class OidcPlugin extends Plugin {
|
|||||||
actions: {
|
actions: {
|
||||||
getAuthUrl,
|
getAuthUrl,
|
||||||
redirect,
|
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.allow('oidc', '*', 'public');
|
||||||
|
|
||||||
this.app.acl.registerSnippet({
|
|
||||||
name: `pm.${this.name}.providers`,
|
|
||||||
actions: ['oidcProviders:*'],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async install(options?: InstallOptions) {}
|
async install(options?: InstallOptions) {}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user