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';
|
||||
|
||||
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 { BlockTemplateDetails, BlockTemplatePage } from '../schema-templates';
|
||||
import { SystemSettingsProvider } from '../system-settings';
|
||||
import { SigninPage, SignupPage } from '../user';
|
||||
import { SigninPageExtensionProvider } from '../user/SigninPageExtension';
|
||||
import { SigninPage, SignupPage } from '../auth';
|
||||
import { SigninPageExtensionProvider } from '../auth/SigninPageExtension';
|
||||
import { compose } from './compose';
|
||||
|
||||
export interface ApplicationOptions {
|
||||
|
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();
|
||||
field.data = field.data || {};
|
||||
field.data.loading = true;
|
||||
try {
|
||||
await resource.update({ filterByTk, values: form.values });
|
||||
ctx.setVisible(false);
|
||||
await form.reset();
|
||||
field.data.loading = false;
|
||||
refresh();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
} finally {
|
||||
field.data.loading = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@ -6,6 +6,7 @@ export * from './api-client';
|
||||
export * from './appInfo';
|
||||
export * from './application';
|
||||
export * from './async-data-provider';
|
||||
export * from './auth';
|
||||
export * from './block-provider';
|
||||
export * from './board';
|
||||
export * from './china-region';
|
||||
|
@ -757,6 +757,7 @@ export default {
|
||||
'Form data templates': '表单数据模板',
|
||||
'Data template': '数据模板',
|
||||
|
||||
'No configuration available.': '无可配置项。',
|
||||
'Reload application': '重载应用',
|
||||
'The application is reloading, please do not close the page.': '应用正在重新加载,请勿关闭页面。',
|
||||
'Application reloading': '应用重新加载中',
|
||||
|
@ -293,7 +293,7 @@ export const PluginCard = (props: { data: IPluginData }) => {
|
||||
onChange={async (checked, e) => {
|
||||
e.stopPropagation();
|
||||
Modal.warn({
|
||||
title: checked ? t('Plugin staring') : t('Plugin stopping'),
|
||||
title: checked ? t('Plugin starting') : t('Plugin stopping'),
|
||||
content: t('The application is reloading, please do not close the page.'),
|
||||
okButtonProps: {
|
||||
style: {
|
||||
|
@ -121,20 +121,20 @@ const schema: ISchema = {
|
||||
});
|
||||
},
|
||||
},
|
||||
allowSignUp: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
'x-content': '{{t("Allow sign up")}}',
|
||||
'x-component': 'Checkbox',
|
||||
'x-decorator': 'FormItem',
|
||||
},
|
||||
smsAuthEnabled: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
'x-content': '{{t("Enable SMS authentication")}}',
|
||||
'x-component': 'Checkbox',
|
||||
'x-decorator': 'FormItem',
|
||||
},
|
||||
// allowSignUp: {
|
||||
// type: 'boolean',
|
||||
// default: true,
|
||||
// 'x-content': '{{t("Allow sign up")}}',
|
||||
// 'x-component': 'Checkbox',
|
||||
// 'x-decorator': 'FormItem',
|
||||
// },
|
||||
// smsAuthEnabled: {
|
||||
// type: 'boolean',
|
||||
// default: false,
|
||||
// 'x-content': '{{t("Enable SMS authentication")}}',
|
||||
// 'x-component': 'Checkbox',
|
||||
// 'x-decorator': 'FormItem',
|
||||
// },
|
||||
footer1: {
|
||||
type: 'void',
|
||||
'x-component': 'Action.Drawer.Footer',
|
||||
@ -227,20 +227,20 @@ const schema2: ISchema = {
|
||||
});
|
||||
},
|
||||
},
|
||||
allowSignUp: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
'x-content': '{{t("Allow sign up")}}',
|
||||
'x-component': 'Checkbox',
|
||||
'x-decorator': 'FormItem',
|
||||
},
|
||||
smsAuthEnabled: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
'x-content': '{{t("Enable SMS authentication")}}',
|
||||
'x-component': 'Checkbox',
|
||||
'x-decorator': 'FormItem',
|
||||
},
|
||||
// allowSignUp: {
|
||||
// type: 'boolean',
|
||||
// default: true,
|
||||
// 'x-content': '{{t("Allow sign up")}}',
|
||||
// 'x-component': 'Checkbox',
|
||||
// 'x-decorator': 'FormItem',
|
||||
// },
|
||||
// smsAuthEnabled: {
|
||||
// type: 'boolean',
|
||||
// default: false,
|
||||
// 'x-content': '{{t("Enable SMS authentication")}}',
|
||||
// 'x-component': 'Checkbox',
|
||||
// 'x-decorator': 'FormItem',
|
||||
// },
|
||||
footer1: {
|
||||
type: 'void',
|
||||
'x-component': 'ActionBar',
|
||||
|
@ -110,8 +110,7 @@ export const CurrentUser = () => {
|
||||
<Menu.Item
|
||||
key="signout"
|
||||
onClick={async () => {
|
||||
await api.resource('users').signout();
|
||||
api.auth.setToken(null);
|
||||
await api.auth.signOut();
|
||||
history.push('/signin');
|
||||
}}
|
||||
>
|
||||
|
@ -12,7 +12,7 @@ export const useCurrentUserContext = () => {
|
||||
export const CurrentUserProvider = (props) => {
|
||||
const location = useLocation();
|
||||
const result = useRequest({
|
||||
url: 'users:check',
|
||||
url: 'auth:check',
|
||||
});
|
||||
if (result.loading) {
|
||||
return <Spin />;
|
||||
|
@ -1,5 +1,5 @@
|
||||
export * from './CurrentUser';
|
||||
export * from './CurrentUserProvider';
|
||||
export * from './SigninPage';
|
||||
export * from './SignupPage';
|
||||
export * from './SigninPageExtension';
|
||||
// export * from './SigninPage';
|
||||
// export * from './SignupPage';
|
||||
// export * from './SigninPageExtension';
|
||||
|
@ -25,22 +25,23 @@ export interface IResource {
|
||||
export class Auth {
|
||||
protected api: APIClient;
|
||||
|
||||
protected NOCOBASE_LOCALE_KEY = 'NOCOBASE_LOCALE';
|
||||
|
||||
protected NOCOBASE_ROLE_KEY = 'NOCOBASE_ROLE';
|
||||
protected KEYS = {
|
||||
locale: 'NOCOBASE_LOCALE',
|
||||
role: 'NOCOBASE_ROLE',
|
||||
token: 'NOCOBASE_TOKEN',
|
||||
authenticator: 'NOCOBASE_AUTH',
|
||||
};
|
||||
|
||||
protected options = {
|
||||
token: null,
|
||||
locale: null,
|
||||
role: null,
|
||||
authenticator: null,
|
||||
token: null,
|
||||
};
|
||||
|
||||
constructor(api: APIClient) {
|
||||
this.api = api;
|
||||
this.initKeys();
|
||||
this.locale = this.getLocale();
|
||||
this.role = this.getRole();
|
||||
this.token = this.getToken();
|
||||
this.api.axios.interceptors.request.use(this.middleware.bind(this));
|
||||
}
|
||||
|
||||
@ -53,34 +54,89 @@ export class Auth {
|
||||
return;
|
||||
}
|
||||
const appName = match[1];
|
||||
this.NOCOBASE_LOCALE_KEY = `${appName.toUpperCase()}_NOCOBASE_LOCALE`;
|
||||
this.NOCOBASE_ROLE_KEY = `${appName.toUpperCase()}_NOCOBASE_ROLE`;
|
||||
this.KEYS['role'] = `${appName.toUpperCase()}_` + this.KEYS['role'];
|
||||
this.KEYS['locale'] = `${appName.toUpperCase()}_` + this.KEYS['locale'];
|
||||
}
|
||||
|
||||
get locale() {
|
||||
return this.getLocale();
|
||||
}
|
||||
|
||||
set locale(value: string) {
|
||||
this.setLocale(value);
|
||||
}
|
||||
|
||||
get role() {
|
||||
return this.getRole();
|
||||
}
|
||||
|
||||
set role(value: string) {
|
||||
this.setRole(value);
|
||||
}
|
||||
|
||||
get token() {
|
||||
return this.getToken();
|
||||
}
|
||||
|
||||
set locale(value) {
|
||||
this.setLocale(value);
|
||||
}
|
||||
|
||||
set role(value) {
|
||||
this.setRole(value);
|
||||
}
|
||||
|
||||
set token(value) {
|
||||
set token(value: string) {
|
||||
this.setToken(value);
|
||||
}
|
||||
|
||||
get authenticator() {
|
||||
return this.getAuthenticator();
|
||||
}
|
||||
|
||||
set authenticator(value: string) {
|
||||
this.setAuthenticator(value);
|
||||
}
|
||||
|
||||
getOption(key: string) {
|
||||
if (!this.KEYS[key]) {
|
||||
return;
|
||||
}
|
||||
return this.api.storage.getItem(this.KEYS[key]);
|
||||
}
|
||||
|
||||
setOption(key: string, value?: string) {
|
||||
if (!this.KEYS[key]) {
|
||||
return;
|
||||
}
|
||||
this.options[key] = value;
|
||||
return this.api.storage.setItem(this.KEYS[key], value || '');
|
||||
}
|
||||
|
||||
getLocale() {
|
||||
return this.getOption('locale');
|
||||
}
|
||||
|
||||
setLocale(locale: string) {
|
||||
this.setOption('locale', locale);
|
||||
}
|
||||
|
||||
getRole() {
|
||||
return this.getOption('role');
|
||||
}
|
||||
|
||||
setRole(role: string) {
|
||||
this.setOption('role', role);
|
||||
}
|
||||
|
||||
getToken() {
|
||||
return this.getOption('token');
|
||||
}
|
||||
|
||||
setToken(token: string) {
|
||||
this.setOption('token', token);
|
||||
}
|
||||
|
||||
getAuthenticator() {
|
||||
return this.getOption('authenticator');
|
||||
}
|
||||
|
||||
setAuthenticator(authenticator: string) {
|
||||
this.setOption('authenticator', authenticator);
|
||||
}
|
||||
|
||||
middleware(config: AxiosRequestConfig) {
|
||||
if (this.locale) {
|
||||
config.headers['X-Locale'] = this.locale;
|
||||
@ -88,63 +144,50 @@ export class Auth {
|
||||
if (this.role) {
|
||||
config.headers['X-Role'] = this.role;
|
||||
}
|
||||
if (this.authenticator && !config.headers['X-Authenticator']) {
|
||||
config.headers['X-Authenticator'] = this.authenticator;
|
||||
}
|
||||
if (this.token) {
|
||||
config.headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
getLocale() {
|
||||
return this.api.storage.getItem(this.NOCOBASE_LOCALE_KEY);
|
||||
}
|
||||
|
||||
setLocale(locale: string) {
|
||||
this.options.locale = locale;
|
||||
this.api.storage.setItem(this.NOCOBASE_LOCALE_KEY, locale || '');
|
||||
}
|
||||
|
||||
getToken() {
|
||||
return this.api.storage.getItem('NOCOBASE_TOKEN');
|
||||
}
|
||||
|
||||
setToken(token: string) {
|
||||
this.options.token = token;
|
||||
this.api.storage.setItem('NOCOBASE_TOKEN', token || '');
|
||||
if (!token) {
|
||||
this.setRole(null);
|
||||
// this.setLocale(null);
|
||||
}
|
||||
}
|
||||
|
||||
getRole() {
|
||||
return this.api.storage.getItem(this.NOCOBASE_ROLE_KEY);
|
||||
}
|
||||
|
||||
setRole(role: string) {
|
||||
this.options.role = role;
|
||||
this.api.storage.setItem(this.NOCOBASE_ROLE_KEY, role || '');
|
||||
}
|
||||
|
||||
async signIn(values, authenticator = 'password'): Promise<AxiosResponse<any>> {
|
||||
async signIn(values: any, authenticator?: string): Promise<AxiosResponse<any>> {
|
||||
const response = await this.api.request({
|
||||
method: 'post',
|
||||
url: 'users:signin',
|
||||
url: 'auth:signIn',
|
||||
data: values,
|
||||
params: {
|
||||
authenticator,
|
||||
headers: {
|
||||
'X-Authenticator': authenticator,
|
||||
},
|
||||
});
|
||||
const data = response?.data?.data;
|
||||
this.setToken(data?.token);
|
||||
this.setAuthenticator(authenticator);
|
||||
return response;
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
await this.api.request({
|
||||
async signUp(values: any, authenticator?: string): Promise<AxiosResponse<any>> {
|
||||
return await this.api.request({
|
||||
method: 'post',
|
||||
url: 'users:signout',
|
||||
url: 'auth:signUp',
|
||||
data: values,
|
||||
headers: {
|
||||
'X-Authenticator': authenticator,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
const response = await this.api.request({
|
||||
method: 'post',
|
||||
url: 'auth:signOut',
|
||||
});
|
||||
this.setToken(null);
|
||||
this.setRole(null);
|
||||
this.setAuthenticator(null);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -24,13 +24,16 @@ describe('api-client', () => {
|
||||
baseURL: 'https://localhost:8000/api',
|
||||
});
|
||||
const mock = new MockAdapter(api.axios);
|
||||
mock.onPost('users:signin').reply(200, {
|
||||
mock.onPost('auth:signIn').reply(200, {
|
||||
data: { id: 1, name: 'John Smith', token: '123' },
|
||||
});
|
||||
const response = await api.auth.signIn({});
|
||||
const response = await api.auth.signIn({}, 'basic');
|
||||
expect(response.status).toBe(200);
|
||||
expect(api.auth.token).toBe('123');
|
||||
expect(localStorage.getItem('NOCOBASE_TOKEN')).toBe('123');
|
||||
expect(api.auth.getToken()).toBe('123');
|
||||
const token = localStorage.getItem('NOCOBASE_TOKEN');
|
||||
expect(token).toBe('123');
|
||||
const auth = localStorage.getItem('NOCOBASE_AUTH');
|
||||
expect(auth).toBe('basic');
|
||||
});
|
||||
|
||||
test('resource action', async () => {
|
||||
@ -72,14 +75,15 @@ describe('api-client', () => {
|
||||
});
|
||||
|
||||
const mock = new MockAdapter(api.axios);
|
||||
mock.onPost('users:signin').reply(200, {
|
||||
mock.onPost('auth:signIn').reply(200, {
|
||||
data: { id: 1, name: 'John Smith', token: '123' },
|
||||
});
|
||||
|
||||
const response = await api.auth.signIn({});
|
||||
expect(response.status).toBe(200);
|
||||
expect(api.auth.token).toBe('123');
|
||||
expect(items.get('NOCOBASE_TOKEN')).toBe('123');
|
||||
expect(api.auth.getToken()).toBe('123');
|
||||
const token = items.get('NOCOBASE_TOKEN');
|
||||
expect(token).toBe('123');
|
||||
});
|
||||
|
||||
test('custom auth', async () => {
|
||||
@ -87,10 +91,11 @@ describe('api-client', () => {
|
||||
async signIn(values: any): Promise<AxiosResponse<any, any>> {
|
||||
const response = await this.api.request({
|
||||
method: 'post',
|
||||
url: 'users:test',
|
||||
url: 'auth:test',
|
||||
data: values,
|
||||
});
|
||||
const data = response?.data?.data;
|
||||
this.setAuthenticator('test');
|
||||
this.setToken(data?.token);
|
||||
return response;
|
||||
}
|
||||
@ -104,13 +109,16 @@ describe('api-client', () => {
|
||||
expect(api.auth).toBeInstanceOf(TestAuth);
|
||||
|
||||
const mock = new MockAdapter(api.axios);
|
||||
mock.onPost('users:test').reply(200, {
|
||||
mock.onPost('auth:test').reply(200, {
|
||||
data: { id: 1, name: 'John Smith', token: '123' },
|
||||
});
|
||||
|
||||
const response = await api.auth.signIn({});
|
||||
expect(response.status).toBe(200);
|
||||
expect(api.auth.token).toBe('123');
|
||||
expect(localStorage.getItem('NOCOBASE_TOKEN')).toBe('123');
|
||||
expect(api.auth.getToken()).toBe('123');
|
||||
const token = localStorage.getItem('NOCOBASE_TOKEN');
|
||||
expect(token).toBe('123');
|
||||
const auth = localStorage.getItem('NOCOBASE_AUTH');
|
||||
expect(auth).toBe('test');
|
||||
});
|
||||
});
|
||||
|
@ -10,6 +10,7 @@
|
||||
"@koa/router": "^9.4.0",
|
||||
"@nocobase/acl": "0.9.4-alpha.2",
|
||||
"@nocobase/actions": "0.9.4-alpha.2",
|
||||
"@nocobase/auth": "0.9.4-alpha.2",
|
||||
"@nocobase/database": "0.9.4-alpha.2",
|
||||
"@nocobase/logger": "0.9.4-alpha.2",
|
||||
"@nocobase/resourcer": "0.9.4-alpha.2",
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { ACL } from '@nocobase/acl';
|
||||
import { registerActions } from '@nocobase/actions';
|
||||
import { actions as authActions, AuthManager } from '@nocobase/auth';
|
||||
import { Cache, createCache, ICacheConfig } from '@nocobase/cache';
|
||||
import Database, { Collection, CollectionOptions, IDatabaseOptions } from '@nocobase/database';
|
||||
import { AppLoggerOptions, createAppLogger, Logger } from '@nocobase/logger';
|
||||
@ -164,6 +165,8 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
|
||||
protected _appManager: AppManager;
|
||||
|
||||
protected _authManager: AuthManager;
|
||||
|
||||
protected _version: ApplicationVersion;
|
||||
|
||||
protected plugins = new Map<string, Plugin>();
|
||||
@ -215,6 +218,10 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
return this._appManager;
|
||||
}
|
||||
|
||||
get authManager() {
|
||||
return this._authManager;
|
||||
}
|
||||
|
||||
get logger() {
|
||||
return this._logger;
|
||||
}
|
||||
@ -277,8 +284,18 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
||||
this._appManager = new AppManager(this);
|
||||
}
|
||||
|
||||
this._authManager = new AuthManager({
|
||||
authKey: 'X-Authenticator',
|
||||
default: 'basic',
|
||||
});
|
||||
this.resource({
|
||||
name: 'auth',
|
||||
actions: authActions,
|
||||
});
|
||||
this._resourcer.use(this._authManager.middleware(), { tag: 'auth' });
|
||||
|
||||
if (this.options.acl !== false) {
|
||||
this._resourcer.use(this._acl.middleware(), { tag: 'acl', after: ['parseToken'] });
|
||||
this._resourcer.use(this._acl.middleware(), { tag: 'acl', after: ['auth'] });
|
||||
}
|
||||
|
||||
registerMiddlewares(this, options);
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { Database, mockDatabase } from '@nocobase/database';
|
||||
import Application, { ApplicationOptions, PluginManager } from '@nocobase/server';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import qs from 'qs';
|
||||
import supertest, { SuperAgentTest } from 'supertest';
|
||||
|
||||
@ -80,11 +81,30 @@ export class MockServer extends Application {
|
||||
await this.db.clean({ drop: true });
|
||||
}
|
||||
|
||||
agent(): SuperAgentTest & { resource: (name: string, resourceOf?: any) => Resource } {
|
||||
agent(): SuperAgentTest & {
|
||||
login: (user: any) => SuperAgentTest;
|
||||
loginUsingId: (userId: number) => SuperAgentTest;
|
||||
resource: (name: string, resourceOf?: any) => Resource;
|
||||
} {
|
||||
const agent = supertest.agent(this.appManager.callback());
|
||||
const prefix = this.resourcer.options.prefix;
|
||||
const proxy = new Proxy(agent, {
|
||||
get(target, method: string, receiver) {
|
||||
if (['login', 'loginUsingId'].includes(method)) {
|
||||
return (userOrId: any) => {
|
||||
return proxy
|
||||
.auth(
|
||||
jwt.sign(
|
||||
{
|
||||
userId: typeof userOrId === 'number' ? userOrId : userOrId?.id,
|
||||
},
|
||||
process.env.APP_KEY,
|
||||
),
|
||||
{ type: 'bearer' },
|
||||
)
|
||||
.set('X-Authenticator', 'basic');
|
||||
};
|
||||
}
|
||||
if (method === 'resource') {
|
||||
return (name: string, resourceOf?: any) => {
|
||||
const keys = name.split('.');
|
||||
|
@ -32,15 +32,7 @@ describe('acl', () => {
|
||||
},
|
||||
});
|
||||
|
||||
userPlugin = app.getPlugin('users') as UsersPlugin;
|
||||
|
||||
adminAgent = app.agent().auth(
|
||||
userPlugin.jwtService.sign({
|
||||
userId: admin.get('id'),
|
||||
}),
|
||||
{ type: 'bearer' },
|
||||
);
|
||||
|
||||
adminAgent = app.agent().login(admin);
|
||||
uiSchemaRepository = db.getRepository('uiSchemas');
|
||||
});
|
||||
|
||||
@ -248,12 +240,7 @@ describe('acl', () => {
|
||||
});
|
||||
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
||||
|
||||
const adminAgent = app.agent().auth(
|
||||
userPlugin.jwtService.sign({
|
||||
userId: rootUser.get('id'),
|
||||
}),
|
||||
{ type: 'bearer' },
|
||||
);
|
||||
const adminAgent = app.agent().login(rootUser);
|
||||
|
||||
expect(await db.getCollection('roles').repository.count()).toBe(3);
|
||||
|
||||
@ -759,13 +746,7 @@ describe('acl', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
||||
const userAgent = app.agent().auth(
|
||||
userPlugin.jwtService.sign({
|
||||
userId: user.get('id'),
|
||||
}),
|
||||
{ type: 'bearer' },
|
||||
);
|
||||
const userAgent = app.agent().login(user);
|
||||
|
||||
const schema = {
|
||||
'x-uid': 'test',
|
||||
@ -823,21 +804,11 @@ describe('acl', () => {
|
||||
});
|
||||
|
||||
it('should destroy new role when user are root user', async () => {
|
||||
const roles = await db.getRepository('roles').find();
|
||||
|
||||
const users = await db.getRepository('users').find();
|
||||
const rootUser = await db.getRepository('users').findOne({
|
||||
filterByTk: 1,
|
||||
});
|
||||
|
||||
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
||||
|
||||
const rootAgent = app.agent().auth(
|
||||
userPlugin.jwtService.sign({
|
||||
userId: rootUser.get('id'),
|
||||
}),
|
||||
{ type: 'bearer' },
|
||||
);
|
||||
const rootAgent = app.agent().login(rootUser);
|
||||
|
||||
const response = await rootAgent
|
||||
// @ts-ignore
|
||||
|
@ -89,14 +89,7 @@ describe('association test', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
||||
|
||||
const userAgent = app.agent().auth(
|
||||
userPlugin.jwtService.sign({
|
||||
userId: user.get('id'),
|
||||
}),
|
||||
{ type: 'bearer' },
|
||||
);
|
||||
const userAgent = app.agent().login(user);
|
||||
|
||||
//@ts-ignore
|
||||
const response = await userAgent.resource('posts').list({});
|
||||
@ -153,19 +146,9 @@ describe('association field acl', () => {
|
||||
});
|
||||
|
||||
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
||||
userAgent = app.agent().auth(
|
||||
userPlugin.jwtService.sign({
|
||||
userId: user.get('id'),
|
||||
}),
|
||||
{ type: 'bearer' },
|
||||
);
|
||||
userAgent = app.agent().login(user);
|
||||
|
||||
adminAgent = app.agent().auth(
|
||||
userPlugin.jwtService.sign({
|
||||
userId: admin.get('id'),
|
||||
}),
|
||||
{ type: 'bearer' },
|
||||
);
|
||||
adminAgent = app.agent().login(admin);
|
||||
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
|
@ -46,19 +46,9 @@ describe('configuration', () => {
|
||||
});
|
||||
|
||||
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
||||
adminAgent = app.agent().auth(
|
||||
userPlugin.jwtService.sign({
|
||||
userId: admin.get('id'),
|
||||
}),
|
||||
{ type: 'bearer' },
|
||||
);
|
||||
adminAgent = app.agent().login(admin);
|
||||
|
||||
userAgent = app.agent().auth(
|
||||
userPlugin.jwtService.sign({
|
||||
userId: user.get('id'),
|
||||
}),
|
||||
{ type: 'bearer' },
|
||||
);
|
||||
userAgent = app.agent().login(user);
|
||||
|
||||
guestAgent = app.agent();
|
||||
});
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Database } from '@nocobase/database';
|
||||
import { prepareApp } from './prepare';
|
||||
import { MockServer } from '@nocobase/test';
|
||||
import { prepareApp } from './prepare';
|
||||
|
||||
describe('list action with acl', () => {
|
||||
let app: MockServer;
|
||||
@ -302,15 +302,7 @@ describe('list association action with acl', () => {
|
||||
});
|
||||
|
||||
const userPlugin = app.getPlugin('users');
|
||||
const userAgent = app
|
||||
.agent()
|
||||
.set('X-With-ACL-Meta', true)
|
||||
.auth(
|
||||
userPlugin.jwtService.sign({
|
||||
userId: user.get('id'),
|
||||
}),
|
||||
{ type: 'bearer' },
|
||||
);
|
||||
const userAgent = app.agent().login(user).set('X-With-ACL-Meta', true);
|
||||
|
||||
await userAgent.resource('posts').create({
|
||||
values: {
|
||||
@ -349,16 +341,7 @@ describe('list association action with acl', () => {
|
||||
});
|
||||
|
||||
const userPlugin = app.getPlugin('users');
|
||||
const agent = app
|
||||
.agent()
|
||||
.set('X-With-ACL-Meta', true)
|
||||
.auth(
|
||||
userPlugin.jwtService.sign({
|
||||
userId: user.get('id'),
|
||||
}),
|
||||
{ type: 'bearer' },
|
||||
);
|
||||
|
||||
const agent = app.agent().login(user).set('X-With-ACL-Meta', true);
|
||||
app.acl.allow('table_a', ['*']);
|
||||
app.acl.allow('collections', ['*']);
|
||||
|
||||
|
@ -31,12 +31,7 @@ describe('middleware', () => {
|
||||
});
|
||||
|
||||
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
||||
adminAgent = app.agent().auth(
|
||||
userPlugin.jwtService.sign({
|
||||
userId: admin.get('id'),
|
||||
}),
|
||||
{ type: 'bearer' },
|
||||
);
|
||||
adminAgent = app.agent().login(admin);
|
||||
|
||||
await db.getRepository('collections').create({
|
||||
values: {
|
||||
|
@ -2,7 +2,6 @@ import { ACL } from '@nocobase/acl';
|
||||
import { Database } from '@nocobase/database';
|
||||
import PluginUser from '@nocobase/plugin-users';
|
||||
import { MockServer } from '@nocobase/test';
|
||||
|
||||
import { prepareApp } from './prepare';
|
||||
|
||||
describe('own test', () => {
|
||||
@ -69,9 +68,7 @@ describe('own test', () => {
|
||||
|
||||
pluginUser = app.getPlugin('users');
|
||||
|
||||
adminToken = pluginUser.jwtService.sign({ userId: admin.get('id') });
|
||||
|
||||
adminAgent = app.agent().auth(adminToken, { type: 'bearer' });
|
||||
adminAgent = app.agent().login(admin);
|
||||
|
||||
user = await db.getRepository('users').create({
|
||||
values: {
|
||||
@ -80,9 +77,7 @@ describe('own test', () => {
|
||||
},
|
||||
});
|
||||
|
||||
userToken = pluginUser.jwtService.sign({ userId: user.get('id') });
|
||||
|
||||
userAgent = app.agent().auth(userToken, { type: 'bearer' });
|
||||
userAgent = app.agent().login(user);
|
||||
});
|
||||
|
||||
it('should list without createBy', async () => {
|
||||
|
@ -5,7 +5,7 @@ export async function prepareApp(): Promise<MockServer> {
|
||||
const app = mockServer({
|
||||
registerActions: true,
|
||||
acl: true,
|
||||
plugins: ['error-handler', 'users', 'ui-schema-storage', 'collection-manager'],
|
||||
plugins: ['error-handler', 'users', 'ui-schema-storage', 'collection-manager', 'auth'],
|
||||
});
|
||||
|
||||
app.plugin(PluginACL, {
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { Database } from '@nocobase/database';
|
||||
import UsersPlugin from '@nocobase/plugin-users';
|
||||
import { MockServer } from '@nocobase/test';
|
||||
|
||||
import { prepareApp } from './prepare';
|
||||
|
||||
describe('role check action', () => {
|
||||
@ -37,12 +36,7 @@ describe('role check action', () => {
|
||||
});
|
||||
|
||||
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
||||
const agent = app.agent().auth(
|
||||
userPlugin.jwtService.sign({
|
||||
userId: user.get('id'),
|
||||
}),
|
||||
{ type: 'bearer' },
|
||||
);
|
||||
const agent = app.agent().login(user);
|
||||
|
||||
// @ts-ignore
|
||||
const response = await agent.resource('roles').check();
|
||||
|
@ -33,12 +33,7 @@ describe('role resource api', () => {
|
||||
});
|
||||
|
||||
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
||||
adminAgent = app.agent().auth(
|
||||
userPlugin.jwtService.sign({
|
||||
userId: admin.get('id'),
|
||||
}),
|
||||
{ type: 'bearer' },
|
||||
);
|
||||
adminAgent = app.agent().login(admin);
|
||||
});
|
||||
|
||||
it('should grant resource by createRepository', async () => {
|
||||
|
@ -2,6 +2,8 @@ import Database, { BelongsToManyRepository } from '@nocobase/database';
|
||||
import PluginACL from '@nocobase/plugin-acl';
|
||||
import UsersPlugin from '@nocobase/plugin-users';
|
||||
import { MockServer, mockServer } from '@nocobase/test';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import AuthPlugin from '@nocobase/plugin-auth';
|
||||
|
||||
describe('role', () => {
|
||||
let api: MockServer;
|
||||
@ -14,6 +16,7 @@ describe('role', () => {
|
||||
await api.cleanDb();
|
||||
api.plugin(UsersPlugin, { name: 'users' });
|
||||
api.plugin(PluginACL, { name: 'acl' });
|
||||
api.plugin(AuthPlugin, { name: 'auth' });
|
||||
await api.loadAndInstall();
|
||||
|
||||
db = api.db;
|
||||
@ -102,7 +105,7 @@ describe('role', () => {
|
||||
await userRolesRepo.add('test1');
|
||||
await userRolesRepo.add('test2');
|
||||
|
||||
const userToken = usersPlugin.jwtService.sign({ userId: user.get('id') });
|
||||
const userToken = jwt.sign({ userId: user.get('id') }, 'test-key');
|
||||
const response = await api
|
||||
.agent()
|
||||
.post('/users:setDefaultRole')
|
||||
@ -111,6 +114,7 @@ describe('role', () => {
|
||||
})
|
||||
.set({
|
||||
Authorization: `Bearer ${userToken}`,
|
||||
'X-Authenticator': 'basic',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toEqual(200);
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { ArrayFieldRepository, Database, Model } from '@nocobase/database';
|
||||
import UsersPlugin from '@nocobase/plugin-users';
|
||||
import { MockServer } from '@nocobase/test';
|
||||
|
||||
import { prepareApp } from './prepare';
|
||||
|
||||
describe('role api', () => {
|
||||
@ -37,12 +36,7 @@ describe('role api', () => {
|
||||
});
|
||||
|
||||
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
||||
adminAgent = app.agent().auth(
|
||||
userPlugin.jwtService.sign({
|
||||
userId: admin.get('id'),
|
||||
}),
|
||||
{ type: 'bearer' },
|
||||
);
|
||||
adminAgent = app.agent().login(admin);
|
||||
});
|
||||
|
||||
it('should list actions', async () => {
|
||||
|
@ -26,12 +26,7 @@ describe('scope api', () => {
|
||||
});
|
||||
|
||||
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
||||
adminAgent = app.agent().auth(
|
||||
userPlugin.jwtService.sign({
|
||||
userId: admin.get('id'),
|
||||
}),
|
||||
{ type: 'bearer' },
|
||||
);
|
||||
adminAgent = app.agent().login(admin);
|
||||
});
|
||||
|
||||
it('should create scope of resource', async () => {
|
||||
|
@ -27,13 +27,7 @@ describe('snippet', () => {
|
||||
});
|
||||
|
||||
const userPlugin: any = app.getPlugin('users');
|
||||
const userAgent: any = app.agent().auth(
|
||||
userPlugin.jwtService.sign({
|
||||
userId: user.get('id'),
|
||||
}),
|
||||
{ type: 'bearer' },
|
||||
);
|
||||
|
||||
const userAgent: any = app.agent().login(user);
|
||||
const createCollectionResponse = await userAgent.resource('collections').create({});
|
||||
|
||||
expect(createCollectionResponse.statusCode).toEqual(403);
|
||||
|
@ -27,12 +27,7 @@ describe('actions', () => {
|
||||
});
|
||||
|
||||
agent = app.agent();
|
||||
adminAgent = app.agent().auth(
|
||||
pluginUser.jwtService.sign({
|
||||
userId: adminUser.get('id'),
|
||||
}),
|
||||
{ type: 'bearer' },
|
||||
);
|
||||
adminAgent = app.agent().login(adminUser);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@ -70,7 +65,7 @@ describe('actions', () => {
|
||||
},
|
||||
});
|
||||
|
||||
let response = await agent.post('/users:signin').send({
|
||||
let response = await agent.post('/auth:signIn').send({
|
||||
email: 'test2@nocobase.com',
|
||||
password: '123456',
|
||||
});
|
||||
@ -89,7 +84,7 @@ describe('actions', () => {
|
||||
filterByTk: 'test',
|
||||
});
|
||||
|
||||
response = await agent.post('/users:signin').send({
|
||||
response = await agent.post('/auth:signIn').send({
|
||||
email: 'test2@nocobase.com',
|
||||
password: '123456',
|
||||
});
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { MockServer } from '@nocobase/test';
|
||||
import { Database } from '@nocobase/database';
|
||||
import { prepareApp } from './prepare';
|
||||
import UsersPlugin from '@nocobase/plugin-users';
|
||||
|
||||
describe('write role to acl', () => {
|
||||
let app: MockServer;
|
||||
@ -32,13 +31,7 @@ describe('write role to acl', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const userPlugin = app.getPlugin('users') as UsersPlugin;
|
||||
const agent = app.agent().auth(
|
||||
userPlugin.jwtService.sign({
|
||||
userId: user.get('id'),
|
||||
}),
|
||||
{ type: 'bearer' },
|
||||
);
|
||||
const agent = app.agent().login(user);
|
||||
|
||||
// @ts-ignore
|
||||
const response = await agent.resource('roles').check();
|
||||
|
@ -434,7 +434,7 @@ export class PluginACL extends Plugin {
|
||||
});
|
||||
});
|
||||
|
||||
this.app.resourcer.use(setCurrentRole, { tag: 'setCurrentRole', before: 'acl', after: 'parseToken' });
|
||||
this.app.resourcer.use(setCurrentRole, { tag: 'setCurrentRole', before: 'acl', after: 'auth' });
|
||||
|
||||
this.app.acl.allow('users', 'setDefaultRole', 'loggedIn');
|
||||
this.app.acl.allow('roles', 'check', 'loggedIn');
|
||||
|
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",
|
||||
"displayName": "OpenID Connect",
|
||||
"displayName.zh-CN": "OpenID Connect",
|
||||
"description": " provide OIDC authentication and authorization functionality for applications or authentication systems.",
|
||||
"description.zh-CN": "在为应用程序或身份验证系统提供OIDC认证和授权功能。",
|
||||
"description": "Provides OIDC authentication and authorization functionality for applications or authentication systems.",
|
||||
"description.zh-CN": "为应用程序或身份验证系统提供OIDC认证和授权功能。",
|
||||
"version": "0.9.4-alpha.2",
|
||||
"license": "AGPL-3.0",
|
||||
"main": "./lib/index.js",
|
||||
"types": "./lib/index.d.ts",
|
||||
"devDependencies": {
|
||||
"@nocobase/auth": "0.9.4-alpha.2",
|
||||
"@nocobase/server": "0.9.4-alpha.2",
|
||||
"@nocobase/test": "0.9.4-alpha.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"openid-client": "^5.3.0"
|
||||
"nanoid": "3.3.4",
|
||||
"openid-client": "^5.4.2"
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { LoginOutlined } from '@ant-design/icons';
|
||||
import { css } from '@emotion/css';
|
||||
import { useAPIClient, useRedirect, useRequest } from '@nocobase/client';
|
||||
import { Authenticator, useAPIClient, useRedirect } from '@nocobase/client';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { Button, Space } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
@ -10,25 +10,20 @@ export interface OIDCProvider {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export const OIDCList = () => {
|
||||
export const OIDCButton = (props: { authenticator: Authenticator }) => {
|
||||
const [windowHandler, setWindowHandler] = useState<Window | undefined>();
|
||||
const api = useAPIClient();
|
||||
const redirect = useRedirect();
|
||||
|
||||
const { data, loading } = useRequest({
|
||||
resource: 'oidc',
|
||||
action: 'getEnabledProviders',
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开登录弹出框
|
||||
*/
|
||||
const handleOpen = useMemoizedFn(async (item: OIDCProvider) => {
|
||||
const handleOpen = async (name: string) => {
|
||||
const response = await api.request({
|
||||
method: 'post',
|
||||
url: 'oidc:getAuthUrl',
|
||||
data: {
|
||||
clientId: item.clientId,
|
||||
headers: {
|
||||
'X-Authenticator': name,
|
||||
},
|
||||
});
|
||||
|
||||
@ -44,16 +39,24 @@ export const OIDCList = () => {
|
||||
);
|
||||
|
||||
setWindowHandler(win);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 从弹出窗口,发消息回来进行登录
|
||||
*/
|
||||
const handleOIDCLogin = useMemoizedFn(async (event: MessageEvent) => {
|
||||
await api.auth.signIn(event.data, 'oidc');
|
||||
const { state } = event.data;
|
||||
const search = new URLSearchParams(state);
|
||||
const authenticator = search.get('name');
|
||||
try {
|
||||
await api.auth.signIn(event.data, authenticator);
|
||||
redirect();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
windowHandler.close();
|
||||
setWindowHandler(undefined);
|
||||
redirect();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@ -61,12 +64,14 @@ export const OIDCList = () => {
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!windowHandler) return;
|
||||
|
||||
window.addEventListener('message', handleOIDCLogin);
|
||||
return () => {
|
||||
window.removeEventListener('message', handleOIDCLogin);
|
||||
};
|
||||
}, [windowHandler]);
|
||||
}, [windowHandler, handleOIDCLogin]);
|
||||
|
||||
const authenticator = props.authenticator;
|
||||
return (
|
||||
<Space
|
||||
direction="vertical"
|
||||
@ -74,11 +79,9 @@ export const OIDCList = () => {
|
||||
display: flex;
|
||||
`}
|
||||
>
|
||||
{data?.data?.map?.((item: OIDCProvider) => (
|
||||
<Button shape="round" block key={item.clientId} icon={<LoginOutlined />} onClick={() => handleOpen(item)}>
|
||||
{item.title}
|
||||
<Button shape="round" block icon={<LoginOutlined />} onClick={() => handleOpen(authenticator.name)}>
|
||||
{authenticator.title}
|
||||
</Button>
|
||||
))}
|
||||
</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 React, { useContext } from 'react';
|
||||
import { useOidcTranslation } from './locale';
|
||||
import { OIDCList } from './OIDCList';
|
||||
import { OIDCPanel } from './OIDCPanel';
|
||||
import { OptionsComponentProvider, SigninPageExtensionProvider } from '@nocobase/client';
|
||||
import React from 'react';
|
||||
import { OIDCButton } from './OIDCButton';
|
||||
import { authType } from '../constants';
|
||||
import { Options } from './Options';
|
||||
|
||||
export default function (props) {
|
||||
const { t } = useOidcTranslation();
|
||||
const ctx = useContext(PluginManagerContext);
|
||||
return (
|
||||
<SigninPageExtensionProvider component={OIDCList}>
|
||||
<SettingsCenterProvider
|
||||
settings={{
|
||||
oidc: {
|
||||
title: t('OIDC manager'),
|
||||
icon: 'FileOutlined',
|
||||
tabs: {
|
||||
providers: {
|
||||
title: t('OIDC Providers'),
|
||||
component: OIDCPanel,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PluginManagerContext.Provider
|
||||
value={{
|
||||
components: {
|
||||
...ctx?.components,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SigninPageExtensionProvider component={OIDCButton} authType={authType}>
|
||||
<OptionsComponentProvider authType={authType} component={Options}>
|
||||
{props.children}
|
||||
</PluginManagerContext.Provider>
|
||||
</SettingsCenterProvider>
|
||||
</OptionsComponentProvider>
|
||||
</SigninPageExtensionProvider>
|
||||
);
|
||||
}
|
||||
|
@ -1,25 +1,8 @@
|
||||
export default {
|
||||
Issuer: 'Issuer',
|
||||
Enable: '启用',
|
||||
Actions: '操作',
|
||||
Delete: '删除',
|
||||
Edit: '编辑',
|
||||
'Button title': '登录按钮标题',
|
||||
'OIDC manager': 'OIDC 管理',
|
||||
'OIDC Providers': 'OIDC 身份提供者',
|
||||
'Provider name': '名称',
|
||||
'Client id': '客户端 id',
|
||||
'Client secret': '客户端 secret',
|
||||
'Openid configuration': '服务发现地址',
|
||||
'Authorization endpoint': '授权端点',
|
||||
'Access token endpoint': '令牌端点',
|
||||
'JWKS endpoint': 'JWKS 公钥端点',
|
||||
'Userinfo endpoint': '用户信息端点',
|
||||
'Redirect url': '重定向地址',
|
||||
'Logout endpoint': '登出端点',
|
||||
'Id token sign alg': 'Id token 签名算法',
|
||||
'Add provider': '添加',
|
||||
'Edit provider': '编辑',
|
||||
'Delete provider': '删除',
|
||||
'Sign in button name, which will be displayed on the sign in page': '登录按钮名称,将在登录页中显示',
|
||||
Copied: '已复制',
|
||||
'Field Map': '字段映射',
|
||||
};
|
||||
|
@ -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 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 { createOIDCClient } from '../shared/createOIDCClient';
|
||||
import { OIDCProvider } from '../shared/types';
|
||||
import { Context, Next } from '@nocobase/actions';
|
||||
import { OIDCAuth } from '../oidc-auth';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { cookieName } from '../../constants';
|
||||
|
||||
export const getAuthUrl = async (ctx: Context, next) => {
|
||||
const {
|
||||
params: { values },
|
||||
} = ctx.action;
|
||||
const providerRepo = ctx.db.getRepository('oidcProviders');
|
||||
const record = await providerRepo.findOne({
|
||||
filter: {
|
||||
clientId: values.clientId,
|
||||
},
|
||||
export const getAuthUrl = async (ctx: Context, next: Next) => {
|
||||
const auth = ctx.auth as OIDCAuth;
|
||||
const client = await auth.createOIDCClient();
|
||||
const { scope } = auth.getOptions();
|
||||
const token = nanoid(15);
|
||||
ctx.cookies.set(cookieName, token, {
|
||||
httpOnly: true,
|
||||
domain: ctx.hostname,
|
||||
});
|
||||
const provider: OIDCProvider = record.toJSON();
|
||||
const client = await createOIDCClient(provider);
|
||||
|
||||
ctx.body = client.authorizationUrl({
|
||||
nonce: ctx.OIDC_NONCE,
|
||||
scope: 'openid profile',
|
||||
response_type: 'code',
|
||||
scope: scope || 'openid email profile',
|
||||
redirect_uri: auth.getRedirectUri(),
|
||||
state: `token=${token}&name=${ctx.headers['x-authenticator']}`,
|
||||
});
|
||||
|
||||
return next();
|
||||
|
@ -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 { generators } from 'openid-client';
|
||||
import { resolve } from 'path';
|
||||
import { getAuthUrl } from './actions/getAuthUrl';
|
||||
import { getEnabledProviders } from './actions/getEnabledProviders';
|
||||
import { redirect } from './actions/redirect';
|
||||
import { oidc } from './authenticators/oidc';
|
||||
import { authType } from '../constants';
|
||||
import { OIDCAuth } from './oidc-auth';
|
||||
|
||||
export class OidcPlugin extends Plugin {
|
||||
#OIDC_NONCE = null;
|
||||
|
||||
afterAdd() {}
|
||||
|
||||
beforeLoad() {}
|
||||
|
||||
async load() {
|
||||
// 导入 collection
|
||||
await this.importCollections(resolve(__dirname, './collections'));
|
||||
|
||||
// 获取 User 插件
|
||||
const userPlugin = this.app.getPlugin<any>('users');
|
||||
|
||||
// 注册 OIDC 验证器
|
||||
userPlugin.authenticators.register('oidc', oidc);
|
||||
this.app.authManager.registerTypes(authType, {
|
||||
auth: OIDCAuth,
|
||||
});
|
||||
|
||||
// 注册接口
|
||||
this.app.resource({
|
||||
@ -29,27 +20,10 @@ export class OidcPlugin extends Plugin {
|
||||
actions: {
|
||||
getAuthUrl,
|
||||
redirect,
|
||||
getEnabledProviders,
|
||||
},
|
||||
});
|
||||
|
||||
// 注册中间件,处理 nonce 值
|
||||
this.app.use(async (ctx, next) => {
|
||||
if (ctx.url.startsWith('/api/users:signin?authenticator=oidc')) {
|
||||
ctx.OIDC_NONCE = this.#OIDC_NONCE;
|
||||
}
|
||||
if (ctx.url.startsWith('/api/oidc:getAuthUrl')) {
|
||||
ctx.OIDC_NONCE = this.#OIDC_NONCE = generators.nonce();
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
this.app.acl.allow('oidc', '*', 'public');
|
||||
|
||||
this.app.acl.registerSnippet({
|
||||
name: `pm.${this.name}.providers`,
|
||||
actions: ['oidcProviders:*'],
|
||||
});
|
||||
}
|
||||
|
||||
async install(options?: InstallOptions) {}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user