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:
YANG QIA 2023-06-07 23:46:42 +08:00 committed by GitHub
parent 0c150eaf9b
commit 24601aa66f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
156 changed files with 4554 additions and 1516 deletions

View File

@ -2,3 +2,7 @@ import dotenv from 'dotenv';
import path from 'path'; import path from 'path';
dotenv.config({ path: path.resolve(process.cwd(), '.env.test') }); dotenv.config({ path: path.resolve(process.cwd(), '.env.test') });
// 当前jest版本低polyfill
import { TextEncoder, TextDecoder } from 'util';
Object.assign(global, { TextDecoder, TextEncoder });

View File

@ -0,0 +1 @@
export { default } from '@nocobase/plugin-auth/client';

View File

@ -0,0 +1 @@
export { default } from '@nocobase/plugin-sms-auth/client';

View File

@ -0,0 +1,4 @@
node_modules
*.log
__tests__
src

201
packages/core/auth/LICENSE Normal file
View 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.

View 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"
}
}

View 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);
});
});

View 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;

View 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();
};
}
}

View 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> {}
}

View 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,
};
}
}

View 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);
});
});
}
}

View File

@ -0,0 +1,4 @@
export * from './auth-manager';
export * from './auth';
export * from './base/auth';
export * from './actions';

View File

@ -30,8 +30,8 @@ import { ErrorFallback } from '../schema-component/antd/error-fallback';
import { SchemaInitializerProvider } from '../schema-initializer'; import { SchemaInitializerProvider } from '../schema-initializer';
import { BlockTemplateDetails, BlockTemplatePage } from '../schema-templates'; import { BlockTemplateDetails, BlockTemplatePage } from '../schema-templates';
import { SystemSettingsProvider } from '../system-settings'; import { SystemSettingsProvider } from '../system-settings';
import { SigninPage, SignupPage } from '../user'; import { SigninPage, SignupPage } from '../auth';
import { SigninPageExtensionProvider } from '../user/SigninPageExtension'; import { SigninPageExtensionProvider } from '../auth/SigninPageExtension';
import { compose } from './compose'; import { compose } from './compose';
export interface ApplicationOptions { export interface ApplicationOptions {

View 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) : <></>;
};

View 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>
);
};

View 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>
);
};

View 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 });
};

View File

@ -0,0 +1,4 @@
export * from './SigninPage';
export * from './SignupPage';
export * from './OptionsComponent';
export * from './SigninPageExtension';

View File

@ -362,11 +362,16 @@ export const useUpdateAction = () => {
await form.submit(); await form.submit();
field.data = field.data || {}; field.data = field.data || {};
field.data.loading = true; field.data.loading = true;
await resource.update({ filterByTk, values: form.values }); try {
ctx.setVisible(false); await resource.update({ filterByTk, values: form.values });
await form.reset(); ctx.setVisible(false);
field.data.loading = false; await form.reset();
refresh(); refresh();
} catch (e) {
console.log(e);
} finally {
field.data.loading = false;
}
}, },
}; };
}; };

View File

@ -6,6 +6,7 @@ export * from './api-client';
export * from './appInfo'; export * from './appInfo';
export * from './application'; export * from './application';
export * from './async-data-provider'; export * from './async-data-provider';
export * from './auth';
export * from './block-provider'; export * from './block-provider';
export * from './board'; export * from './board';
export * from './china-region'; export * from './china-region';

View File

@ -757,6 +757,7 @@ export default {
'Form data templates': '表单数据模板', 'Form data templates': '表单数据模板',
'Data template': '数据模板', 'Data template': '数据模板',
'No configuration available.': '无可配置项。',
'Reload application': '重载应用', 'Reload application': '重载应用',
'The application is reloading, please do not close the page.': '应用正在重新加载,请勿关闭页面。', 'The application is reloading, please do not close the page.': '应用正在重新加载,请勿关闭页面。',
'Application reloading': '应用重新加载中', 'Application reloading': '应用重新加载中',

View File

@ -293,7 +293,7 @@ export const PluginCard = (props: { data: IPluginData }) => {
onChange={async (checked, e) => { onChange={async (checked, e) => {
e.stopPropagation(); e.stopPropagation();
Modal.warn({ Modal.warn({
title: checked ? t('Plugin staring') : t('Plugin stopping'), title: checked ? t('Plugin starting') : t('Plugin stopping'),
content: t('The application is reloading, please do not close the page.'), content: t('The application is reloading, please do not close the page.'),
okButtonProps: { okButtonProps: {
style: { style: {

View File

@ -121,20 +121,20 @@ const schema: ISchema = {
}); });
}, },
}, },
allowSignUp: { // allowSignUp: {
type: 'boolean', // type: 'boolean',
default: true, // default: true,
'x-content': '{{t("Allow sign up")}}', // 'x-content': '{{t("Allow sign up")}}',
'x-component': 'Checkbox', // 'x-component': 'Checkbox',
'x-decorator': 'FormItem', // 'x-decorator': 'FormItem',
}, // },
smsAuthEnabled: { // smsAuthEnabled: {
type: 'boolean', // type: 'boolean',
default: false, // default: false,
'x-content': '{{t("Enable SMS authentication")}}', // 'x-content': '{{t("Enable SMS authentication")}}',
'x-component': 'Checkbox', // 'x-component': 'Checkbox',
'x-decorator': 'FormItem', // 'x-decorator': 'FormItem',
}, // },
footer1: { footer1: {
type: 'void', type: 'void',
'x-component': 'Action.Drawer.Footer', 'x-component': 'Action.Drawer.Footer',
@ -227,20 +227,20 @@ const schema2: ISchema = {
}); });
}, },
}, },
allowSignUp: { // allowSignUp: {
type: 'boolean', // type: 'boolean',
default: true, // default: true,
'x-content': '{{t("Allow sign up")}}', // 'x-content': '{{t("Allow sign up")}}',
'x-component': 'Checkbox', // 'x-component': 'Checkbox',
'x-decorator': 'FormItem', // 'x-decorator': 'FormItem',
}, // },
smsAuthEnabled: { // smsAuthEnabled: {
type: 'boolean', // type: 'boolean',
default: false, // default: false,
'x-content': '{{t("Enable SMS authentication")}}', // 'x-content': '{{t("Enable SMS authentication")}}',
'x-component': 'Checkbox', // 'x-component': 'Checkbox',
'x-decorator': 'FormItem', // 'x-decorator': 'FormItem',
}, // },
footer1: { footer1: {
type: 'void', type: 'void',
'x-component': 'ActionBar', 'x-component': 'ActionBar',

View File

@ -110,8 +110,7 @@ export const CurrentUser = () => {
<Menu.Item <Menu.Item
key="signout" key="signout"
onClick={async () => { onClick={async () => {
await api.resource('users').signout(); await api.auth.signOut();
api.auth.setToken(null);
history.push('/signin'); history.push('/signin');
}} }}
> >

View File

@ -12,7 +12,7 @@ export const useCurrentUserContext = () => {
export const CurrentUserProvider = (props) => { export const CurrentUserProvider = (props) => {
const location = useLocation(); const location = useLocation();
const result = useRequest({ const result = useRequest({
url: 'users:check', url: 'auth:check',
}); });
if (result.loading) { if (result.loading) {
return <Spin />; return <Spin />;

View File

@ -1,5 +1,5 @@
export * from './CurrentUser'; export * from './CurrentUser';
export * from './CurrentUserProvider'; export * from './CurrentUserProvider';
export * from './SigninPage'; // export * from './SigninPage';
export * from './SignupPage'; // export * from './SignupPage';
export * from './SigninPageExtension'; // export * from './SigninPageExtension';

View File

@ -25,22 +25,23 @@ export interface IResource {
export class Auth { export class Auth {
protected api: APIClient; protected api: APIClient;
protected NOCOBASE_LOCALE_KEY = 'NOCOBASE_LOCALE'; protected KEYS = {
locale: 'NOCOBASE_LOCALE',
protected NOCOBASE_ROLE_KEY = 'NOCOBASE_ROLE'; role: 'NOCOBASE_ROLE',
token: 'NOCOBASE_TOKEN',
authenticator: 'NOCOBASE_AUTH',
};
protected options = { protected options = {
token: null,
locale: null, locale: null,
role: null, role: null,
authenticator: null,
token: null,
}; };
constructor(api: APIClient) { constructor(api: APIClient) {
this.api = api; this.api = api;
this.initKeys(); this.initKeys();
this.locale = this.getLocale();
this.role = this.getRole();
this.token = this.getToken();
this.api.axios.interceptors.request.use(this.middleware.bind(this)); this.api.axios.interceptors.request.use(this.middleware.bind(this));
} }
@ -53,34 +54,89 @@ export class Auth {
return; return;
} }
const appName = match[1]; const appName = match[1];
this.NOCOBASE_LOCALE_KEY = `${appName.toUpperCase()}_NOCOBASE_LOCALE`; this.KEYS['role'] = `${appName.toUpperCase()}_` + this.KEYS['role'];
this.NOCOBASE_ROLE_KEY = `${appName.toUpperCase()}_NOCOBASE_ROLE`; this.KEYS['locale'] = `${appName.toUpperCase()}_` + this.KEYS['locale'];
} }
get locale() { get locale() {
return this.getLocale(); return this.getLocale();
} }
set locale(value: string) {
this.setLocale(value);
}
get role() { get role() {
return this.getRole(); return this.getRole();
} }
set role(value: string) {
this.setRole(value);
}
get token() { get token() {
return this.getToken(); return this.getToken();
} }
set locale(value) { set token(value: string) {
this.setLocale(value);
}
set role(value) {
this.setRole(value);
}
set token(value) {
this.setToken(value); this.setToken(value);
} }
get authenticator() {
return this.getAuthenticator();
}
set authenticator(value: string) {
this.setAuthenticator(value);
}
getOption(key: string) {
if (!this.KEYS[key]) {
return;
}
return this.api.storage.getItem(this.KEYS[key]);
}
setOption(key: string, value?: string) {
if (!this.KEYS[key]) {
return;
}
this.options[key] = value;
return this.api.storage.setItem(this.KEYS[key], value || '');
}
getLocale() {
return this.getOption('locale');
}
setLocale(locale: string) {
this.setOption('locale', locale);
}
getRole() {
return this.getOption('role');
}
setRole(role: string) {
this.setOption('role', role);
}
getToken() {
return this.getOption('token');
}
setToken(token: string) {
this.setOption('token', token);
}
getAuthenticator() {
return this.getOption('authenticator');
}
setAuthenticator(authenticator: string) {
this.setOption('authenticator', authenticator);
}
middleware(config: AxiosRequestConfig) { middleware(config: AxiosRequestConfig) {
if (this.locale) { if (this.locale) {
config.headers['X-Locale'] = this.locale; config.headers['X-Locale'] = this.locale;
@ -88,63 +144,50 @@ export class Auth {
if (this.role) { if (this.role) {
config.headers['X-Role'] = this.role; config.headers['X-Role'] = this.role;
} }
if (this.authenticator && !config.headers['X-Authenticator']) {
config.headers['X-Authenticator'] = this.authenticator;
}
if (this.token) { if (this.token) {
config.headers['Authorization'] = `Bearer ${this.token}`; config.headers['Authorization'] = `Bearer ${this.token}`;
} }
return config; return config;
} }
getLocale() { async signIn(values: any, authenticator?: string): Promise<AxiosResponse<any>> {
return this.api.storage.getItem(this.NOCOBASE_LOCALE_KEY);
}
setLocale(locale: string) {
this.options.locale = locale;
this.api.storage.setItem(this.NOCOBASE_LOCALE_KEY, locale || '');
}
getToken() {
return this.api.storage.getItem('NOCOBASE_TOKEN');
}
setToken(token: string) {
this.options.token = token;
this.api.storage.setItem('NOCOBASE_TOKEN', token || '');
if (!token) {
this.setRole(null);
// this.setLocale(null);
}
}
getRole() {
return this.api.storage.getItem(this.NOCOBASE_ROLE_KEY);
}
setRole(role: string) {
this.options.role = role;
this.api.storage.setItem(this.NOCOBASE_ROLE_KEY, role || '');
}
async signIn(values, authenticator = 'password'): Promise<AxiosResponse<any>> {
const response = await this.api.request({ const response = await this.api.request({
method: 'post', method: 'post',
url: 'users:signin', url: 'auth:signIn',
data: values, data: values,
params: { headers: {
authenticator, 'X-Authenticator': authenticator,
}, },
}); });
const data = response?.data?.data; const data = response?.data?.data;
this.setToken(data?.token); this.setToken(data?.token);
this.setAuthenticator(authenticator);
return response; return response;
} }
async signOut() { async signUp(values: any, authenticator?: string): Promise<AxiosResponse<any>> {
await this.api.request({ return await this.api.request({
method: 'post', method: 'post',
url: 'users:signout', url: 'auth:signUp',
data: values,
headers: {
'X-Authenticator': authenticator,
},
});
}
async signOut() {
const response = await this.api.request({
method: 'post',
url: 'auth:signOut',
}); });
this.setToken(null); this.setToken(null);
this.setRole(null);
this.setAuthenticator(null);
return response;
} }
} }

View File

@ -24,13 +24,16 @@ describe('api-client', () => {
baseURL: 'https://localhost:8000/api', baseURL: 'https://localhost:8000/api',
}); });
const mock = new MockAdapter(api.axios); const mock = new MockAdapter(api.axios);
mock.onPost('users:signin').reply(200, { mock.onPost('auth:signIn').reply(200, {
data: { id: 1, name: 'John Smith', token: '123' }, data: { id: 1, name: 'John Smith', token: '123' },
}); });
const response = await api.auth.signIn({}); const response = await api.auth.signIn({}, 'basic');
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(api.auth.token).toBe('123'); expect(api.auth.getToken()).toBe('123');
expect(localStorage.getItem('NOCOBASE_TOKEN')).toBe('123'); const token = localStorage.getItem('NOCOBASE_TOKEN');
expect(token).toBe('123');
const auth = localStorage.getItem('NOCOBASE_AUTH');
expect(auth).toBe('basic');
}); });
test('resource action', async () => { test('resource action', async () => {
@ -72,14 +75,15 @@ describe('api-client', () => {
}); });
const mock = new MockAdapter(api.axios); const mock = new MockAdapter(api.axios);
mock.onPost('users:signin').reply(200, { mock.onPost('auth:signIn').reply(200, {
data: { id: 1, name: 'John Smith', token: '123' }, data: { id: 1, name: 'John Smith', token: '123' },
}); });
const response = await api.auth.signIn({}); const response = await api.auth.signIn({});
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(api.auth.token).toBe('123'); expect(api.auth.getToken()).toBe('123');
expect(items.get('NOCOBASE_TOKEN')).toBe('123'); const token = items.get('NOCOBASE_TOKEN');
expect(token).toBe('123');
}); });
test('custom auth', async () => { test('custom auth', async () => {
@ -87,10 +91,11 @@ describe('api-client', () => {
async signIn(values: any): Promise<AxiosResponse<any, any>> { async signIn(values: any): Promise<AxiosResponse<any, any>> {
const response = await this.api.request({ const response = await this.api.request({
method: 'post', method: 'post',
url: 'users:test', url: 'auth:test',
data: values, data: values,
}); });
const data = response?.data?.data; const data = response?.data?.data;
this.setAuthenticator('test');
this.setToken(data?.token); this.setToken(data?.token);
return response; return response;
} }
@ -104,13 +109,16 @@ describe('api-client', () => {
expect(api.auth).toBeInstanceOf(TestAuth); expect(api.auth).toBeInstanceOf(TestAuth);
const mock = new MockAdapter(api.axios); const mock = new MockAdapter(api.axios);
mock.onPost('users:test').reply(200, { mock.onPost('auth:test').reply(200, {
data: { id: 1, name: 'John Smith', token: '123' }, data: { id: 1, name: 'John Smith', token: '123' },
}); });
const response = await api.auth.signIn({}); const response = await api.auth.signIn({});
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(api.auth.token).toBe('123'); expect(api.auth.getToken()).toBe('123');
expect(localStorage.getItem('NOCOBASE_TOKEN')).toBe('123'); const token = localStorage.getItem('NOCOBASE_TOKEN');
expect(token).toBe('123');
const auth = localStorage.getItem('NOCOBASE_AUTH');
expect(auth).toBe('test');
}); });
}); });

View File

@ -10,6 +10,7 @@
"@koa/router": "^9.4.0", "@koa/router": "^9.4.0",
"@nocobase/acl": "0.9.4-alpha.2", "@nocobase/acl": "0.9.4-alpha.2",
"@nocobase/actions": "0.9.4-alpha.2", "@nocobase/actions": "0.9.4-alpha.2",
"@nocobase/auth": "0.9.4-alpha.2",
"@nocobase/database": "0.9.4-alpha.2", "@nocobase/database": "0.9.4-alpha.2",
"@nocobase/logger": "0.9.4-alpha.2", "@nocobase/logger": "0.9.4-alpha.2",
"@nocobase/resourcer": "0.9.4-alpha.2", "@nocobase/resourcer": "0.9.4-alpha.2",

View File

@ -1,5 +1,6 @@
import { ACL } from '@nocobase/acl'; import { ACL } from '@nocobase/acl';
import { registerActions } from '@nocobase/actions'; import { registerActions } from '@nocobase/actions';
import { actions as authActions, AuthManager } from '@nocobase/auth';
import { Cache, createCache, ICacheConfig } from '@nocobase/cache'; import { Cache, createCache, ICacheConfig } from '@nocobase/cache';
import Database, { Collection, CollectionOptions, IDatabaseOptions } from '@nocobase/database'; import Database, { Collection, CollectionOptions, IDatabaseOptions } from '@nocobase/database';
import { AppLoggerOptions, createAppLogger, Logger } from '@nocobase/logger'; import { AppLoggerOptions, createAppLogger, Logger } from '@nocobase/logger';
@ -164,6 +165,8 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
protected _appManager: AppManager; protected _appManager: AppManager;
protected _authManager: AuthManager;
protected _version: ApplicationVersion; protected _version: ApplicationVersion;
protected plugins = new Map<string, Plugin>(); protected plugins = new Map<string, Plugin>();
@ -215,6 +218,10 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
return this._appManager; return this._appManager;
} }
get authManager() {
return this._authManager;
}
get logger() { get logger() {
return this._logger; return this._logger;
} }
@ -277,8 +284,18 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
this._appManager = new AppManager(this); this._appManager = new AppManager(this);
} }
this._authManager = new AuthManager({
authKey: 'X-Authenticator',
default: 'basic',
});
this.resource({
name: 'auth',
actions: authActions,
});
this._resourcer.use(this._authManager.middleware(), { tag: 'auth' });
if (this.options.acl !== false) { if (this.options.acl !== false) {
this._resourcer.use(this._acl.middleware(), { tag: 'acl', after: ['parseToken'] }); this._resourcer.use(this._acl.middleware(), { tag: 'acl', after: ['auth'] });
} }
registerMiddlewares(this, options); registerMiddlewares(this, options);

View File

@ -1,5 +1,6 @@
import { Database, mockDatabase } from '@nocobase/database'; import { Database, mockDatabase } from '@nocobase/database';
import Application, { ApplicationOptions, PluginManager } from '@nocobase/server'; import Application, { ApplicationOptions, PluginManager } from '@nocobase/server';
import jwt from 'jsonwebtoken';
import qs from 'qs'; import qs from 'qs';
import supertest, { SuperAgentTest } from 'supertest'; import supertest, { SuperAgentTest } from 'supertest';
@ -80,11 +81,30 @@ export class MockServer extends Application {
await this.db.clean({ drop: true }); await this.db.clean({ drop: true });
} }
agent(): SuperAgentTest & { resource: (name: string, resourceOf?: any) => Resource } { agent(): SuperAgentTest & {
login: (user: any) => SuperAgentTest;
loginUsingId: (userId: number) => SuperAgentTest;
resource: (name: string, resourceOf?: any) => Resource;
} {
const agent = supertest.agent(this.appManager.callback()); const agent = supertest.agent(this.appManager.callback());
const prefix = this.resourcer.options.prefix; const prefix = this.resourcer.options.prefix;
const proxy = new Proxy(agent, { const proxy = new Proxy(agent, {
get(target, method: string, receiver) { get(target, method: string, receiver) {
if (['login', 'loginUsingId'].includes(method)) {
return (userOrId: any) => {
return proxy
.auth(
jwt.sign(
{
userId: typeof userOrId === 'number' ? userOrId : userOrId?.id,
},
process.env.APP_KEY,
),
{ type: 'bearer' },
)
.set('X-Authenticator', 'basic');
};
}
if (method === 'resource') { if (method === 'resource') {
return (name: string, resourceOf?: any) => { return (name: string, resourceOf?: any) => {
const keys = name.split('.'); const keys = name.split('.');

View File

@ -32,15 +32,7 @@ describe('acl', () => {
}, },
}); });
userPlugin = app.getPlugin('users') as UsersPlugin; adminAgent = app.agent().login(admin);
adminAgent = app.agent().auth(
userPlugin.jwtService.sign({
userId: admin.get('id'),
}),
{ type: 'bearer' },
);
uiSchemaRepository = db.getRepository('uiSchemas'); uiSchemaRepository = db.getRepository('uiSchemas');
}); });
@ -248,12 +240,7 @@ describe('acl', () => {
}); });
const userPlugin = app.getPlugin('users') as UsersPlugin; const userPlugin = app.getPlugin('users') as UsersPlugin;
const adminAgent = app.agent().auth( const adminAgent = app.agent().login(rootUser);
userPlugin.jwtService.sign({
userId: rootUser.get('id'),
}),
{ type: 'bearer' },
);
expect(await db.getCollection('roles').repository.count()).toBe(3); expect(await db.getCollection('roles').repository.count()).toBe(3);
@ -759,13 +746,7 @@ describe('acl', () => {
}, },
}); });
const userPlugin = app.getPlugin('users') as UsersPlugin; const userAgent = app.agent().login(user);
const userAgent = app.agent().auth(
userPlugin.jwtService.sign({
userId: user.get('id'),
}),
{ type: 'bearer' },
);
const schema = { const schema = {
'x-uid': 'test', 'x-uid': 'test',
@ -823,21 +804,11 @@ describe('acl', () => {
}); });
it('should destroy new role when user are root user', async () => { it('should destroy new role when user are root user', async () => {
const roles = await db.getRepository('roles').find();
const users = await db.getRepository('users').find();
const rootUser = await db.getRepository('users').findOne({ const rootUser = await db.getRepository('users').findOne({
filterByTk: 1, filterByTk: 1,
}); });
const userPlugin = app.getPlugin('users') as UsersPlugin; const rootAgent = app.agent().login(rootUser);
const rootAgent = app.agent().auth(
userPlugin.jwtService.sign({
userId: rootUser.get('id'),
}),
{ type: 'bearer' },
);
const response = await rootAgent const response = await rootAgent
// @ts-ignore // @ts-ignore

View File

@ -89,14 +89,7 @@ describe('association test', () => {
}, },
}); });
const userPlugin = app.getPlugin('users') as UsersPlugin; const userAgent = app.agent().login(user);
const userAgent = app.agent().auth(
userPlugin.jwtService.sign({
userId: user.get('id'),
}),
{ type: 'bearer' },
);
//@ts-ignore //@ts-ignore
const response = await userAgent.resource('posts').list({}); const response = await userAgent.resource('posts').list({});
@ -153,19 +146,9 @@ describe('association field acl', () => {
}); });
const userPlugin = app.getPlugin('users') as UsersPlugin; const userPlugin = app.getPlugin('users') as UsersPlugin;
userAgent = app.agent().auth( userAgent = app.agent().login(user);
userPlugin.jwtService.sign({
userId: user.get('id'),
}),
{ type: 'bearer' },
);
adminAgent = app.agent().auth( adminAgent = app.agent().login(admin);
userPlugin.jwtService.sign({
userId: admin.get('id'),
}),
{ type: 'bearer' },
);
await db.getRepository('collections').create({ await db.getRepository('collections').create({
values: { values: {

View File

@ -46,19 +46,9 @@ describe('configuration', () => {
}); });
const userPlugin = app.getPlugin('users') as UsersPlugin; const userPlugin = app.getPlugin('users') as UsersPlugin;
adminAgent = app.agent().auth( adminAgent = app.agent().login(admin);
userPlugin.jwtService.sign({
userId: admin.get('id'),
}),
{ type: 'bearer' },
);
userAgent = app.agent().auth( userAgent = app.agent().login(user);
userPlugin.jwtService.sign({
userId: user.get('id'),
}),
{ type: 'bearer' },
);
guestAgent = app.agent(); guestAgent = app.agent();
}); });

View File

@ -1,6 +1,6 @@
import { Database } from '@nocobase/database'; import { Database } from '@nocobase/database';
import { prepareApp } from './prepare';
import { MockServer } from '@nocobase/test'; import { MockServer } from '@nocobase/test';
import { prepareApp } from './prepare';
describe('list action with acl', () => { describe('list action with acl', () => {
let app: MockServer; let app: MockServer;
@ -302,15 +302,7 @@ describe('list association action with acl', () => {
}); });
const userPlugin = app.getPlugin('users'); const userPlugin = app.getPlugin('users');
const userAgent = app const userAgent = app.agent().login(user).set('X-With-ACL-Meta', true);
.agent()
.set('X-With-ACL-Meta', true)
.auth(
userPlugin.jwtService.sign({
userId: user.get('id'),
}),
{ type: 'bearer' },
);
await userAgent.resource('posts').create({ await userAgent.resource('posts').create({
values: { values: {
@ -349,16 +341,7 @@ describe('list association action with acl', () => {
}); });
const userPlugin = app.getPlugin('users'); const userPlugin = app.getPlugin('users');
const agent = app const agent = app.agent().login(user).set('X-With-ACL-Meta', true);
.agent()
.set('X-With-ACL-Meta', true)
.auth(
userPlugin.jwtService.sign({
userId: user.get('id'),
}),
{ type: 'bearer' },
);
app.acl.allow('table_a', ['*']); app.acl.allow('table_a', ['*']);
app.acl.allow('collections', ['*']); app.acl.allow('collections', ['*']);

View File

@ -31,12 +31,7 @@ describe('middleware', () => {
}); });
const userPlugin = app.getPlugin('users') as UsersPlugin; const userPlugin = app.getPlugin('users') as UsersPlugin;
adminAgent = app.agent().auth( adminAgent = app.agent().login(admin);
userPlugin.jwtService.sign({
userId: admin.get('id'),
}),
{ type: 'bearer' },
);
await db.getRepository('collections').create({ await db.getRepository('collections').create({
values: { values: {

View File

@ -2,7 +2,6 @@ import { ACL } from '@nocobase/acl';
import { Database } from '@nocobase/database'; import { Database } from '@nocobase/database';
import PluginUser from '@nocobase/plugin-users'; import PluginUser from '@nocobase/plugin-users';
import { MockServer } from '@nocobase/test'; import { MockServer } from '@nocobase/test';
import { prepareApp } from './prepare'; import { prepareApp } from './prepare';
describe('own test', () => { describe('own test', () => {
@ -69,9 +68,7 @@ describe('own test', () => {
pluginUser = app.getPlugin('users'); pluginUser = app.getPlugin('users');
adminToken = pluginUser.jwtService.sign({ userId: admin.get('id') }); adminAgent = app.agent().login(admin);
adminAgent = app.agent().auth(adminToken, { type: 'bearer' });
user = await db.getRepository('users').create({ user = await db.getRepository('users').create({
values: { values: {
@ -80,9 +77,7 @@ describe('own test', () => {
}, },
}); });
userToken = pluginUser.jwtService.sign({ userId: user.get('id') }); userAgent = app.agent().login(user);
userAgent = app.agent().auth(userToken, { type: 'bearer' });
}); });
it('should list without createBy', async () => { it('should list without createBy', async () => {

View File

@ -5,7 +5,7 @@ export async function prepareApp(): Promise<MockServer> {
const app = mockServer({ const app = mockServer({
registerActions: true, registerActions: true,
acl: true, acl: true,
plugins: ['error-handler', 'users', 'ui-schema-storage', 'collection-manager'], plugins: ['error-handler', 'users', 'ui-schema-storage', 'collection-manager', 'auth'],
}); });
app.plugin(PluginACL, { app.plugin(PluginACL, {

View File

@ -1,7 +1,6 @@
import { Database } from '@nocobase/database'; import { Database } from '@nocobase/database';
import UsersPlugin from '@nocobase/plugin-users'; import UsersPlugin from '@nocobase/plugin-users';
import { MockServer } from '@nocobase/test'; import { MockServer } from '@nocobase/test';
import { prepareApp } from './prepare'; import { prepareApp } from './prepare';
describe('role check action', () => { describe('role check action', () => {
@ -37,12 +36,7 @@ describe('role check action', () => {
}); });
const userPlugin = app.getPlugin('users') as UsersPlugin; const userPlugin = app.getPlugin('users') as UsersPlugin;
const agent = app.agent().auth( const agent = app.agent().login(user);
userPlugin.jwtService.sign({
userId: user.get('id'),
}),
{ type: 'bearer' },
);
// @ts-ignore // @ts-ignore
const response = await agent.resource('roles').check(); const response = await agent.resource('roles').check();

View File

@ -33,12 +33,7 @@ describe('role resource api', () => {
}); });
const userPlugin = app.getPlugin('users') as UsersPlugin; const userPlugin = app.getPlugin('users') as UsersPlugin;
adminAgent = app.agent().auth( adminAgent = app.agent().login(admin);
userPlugin.jwtService.sign({
userId: admin.get('id'),
}),
{ type: 'bearer' },
);
}); });
it('should grant resource by createRepository', async () => { it('should grant resource by createRepository', async () => {

View File

@ -2,6 +2,8 @@ import Database, { BelongsToManyRepository } from '@nocobase/database';
import PluginACL from '@nocobase/plugin-acl'; import PluginACL from '@nocobase/plugin-acl';
import UsersPlugin from '@nocobase/plugin-users'; import UsersPlugin from '@nocobase/plugin-users';
import { MockServer, mockServer } from '@nocobase/test'; import { MockServer, mockServer } from '@nocobase/test';
import jwt from 'jsonwebtoken';
import AuthPlugin from '@nocobase/plugin-auth';
describe('role', () => { describe('role', () => {
let api: MockServer; let api: MockServer;
@ -14,6 +16,7 @@ describe('role', () => {
await api.cleanDb(); await api.cleanDb();
api.plugin(UsersPlugin, { name: 'users' }); api.plugin(UsersPlugin, { name: 'users' });
api.plugin(PluginACL, { name: 'acl' }); api.plugin(PluginACL, { name: 'acl' });
api.plugin(AuthPlugin, { name: 'auth' });
await api.loadAndInstall(); await api.loadAndInstall();
db = api.db; db = api.db;
@ -102,7 +105,7 @@ describe('role', () => {
await userRolesRepo.add('test1'); await userRolesRepo.add('test1');
await userRolesRepo.add('test2'); await userRolesRepo.add('test2');
const userToken = usersPlugin.jwtService.sign({ userId: user.get('id') }); const userToken = jwt.sign({ userId: user.get('id') }, 'test-key');
const response = await api const response = await api
.agent() .agent()
.post('/users:setDefaultRole') .post('/users:setDefaultRole')
@ -111,6 +114,7 @@ describe('role', () => {
}) })
.set({ .set({
Authorization: `Bearer ${userToken}`, Authorization: `Bearer ${userToken}`,
'X-Authenticator': 'basic',
}); });
expect(response.statusCode).toEqual(200); expect(response.statusCode).toEqual(200);

View File

@ -1,7 +1,6 @@
import { ArrayFieldRepository, Database, Model } from '@nocobase/database'; import { ArrayFieldRepository, Database, Model } from '@nocobase/database';
import UsersPlugin from '@nocobase/plugin-users'; import UsersPlugin from '@nocobase/plugin-users';
import { MockServer } from '@nocobase/test'; import { MockServer } from '@nocobase/test';
import { prepareApp } from './prepare'; import { prepareApp } from './prepare';
describe('role api', () => { describe('role api', () => {
@ -37,12 +36,7 @@ describe('role api', () => {
}); });
const userPlugin = app.getPlugin('users') as UsersPlugin; const userPlugin = app.getPlugin('users') as UsersPlugin;
adminAgent = app.agent().auth( adminAgent = app.agent().login(admin);
userPlugin.jwtService.sign({
userId: admin.get('id'),
}),
{ type: 'bearer' },
);
}); });
it('should list actions', async () => { it('should list actions', async () => {

View File

@ -26,12 +26,7 @@ describe('scope api', () => {
}); });
const userPlugin = app.getPlugin('users') as UsersPlugin; const userPlugin = app.getPlugin('users') as UsersPlugin;
adminAgent = app.agent().auth( adminAgent = app.agent().login(admin);
userPlugin.jwtService.sign({
userId: admin.get('id'),
}),
{ type: 'bearer' },
);
}); });
it('should create scope of resource', async () => { it('should create scope of resource', async () => {

View File

@ -27,13 +27,7 @@ describe('snippet', () => {
}); });
const userPlugin: any = app.getPlugin('users'); const userPlugin: any = app.getPlugin('users');
const userAgent: any = app.agent().auth( const userAgent: any = app.agent().login(user);
userPlugin.jwtService.sign({
userId: user.get('id'),
}),
{ type: 'bearer' },
);
const createCollectionResponse = await userAgent.resource('collections').create({}); const createCollectionResponse = await userAgent.resource('collections').create({});
expect(createCollectionResponse.statusCode).toEqual(403); expect(createCollectionResponse.statusCode).toEqual(403);

View File

@ -27,12 +27,7 @@ describe('actions', () => {
}); });
agent = app.agent(); agent = app.agent();
adminAgent = app.agent().auth( adminAgent = app.agent().login(adminUser);
pluginUser.jwtService.sign({
userId: adminUser.get('id'),
}),
{ type: 'bearer' },
);
}); });
afterEach(async () => { afterEach(async () => {
@ -70,7 +65,7 @@ describe('actions', () => {
}, },
}); });
let response = await agent.post('/users:signin').send({ let response = await agent.post('/auth:signIn').send({
email: 'test2@nocobase.com', email: 'test2@nocobase.com',
password: '123456', password: '123456',
}); });
@ -89,7 +84,7 @@ describe('actions', () => {
filterByTk: 'test', filterByTk: 'test',
}); });
response = await agent.post('/users:signin').send({ response = await agent.post('/auth:signIn').send({
email: 'test2@nocobase.com', email: 'test2@nocobase.com',
password: '123456', password: '123456',
}); });

View File

@ -1,7 +1,6 @@
import { MockServer } from '@nocobase/test'; import { MockServer } from '@nocobase/test';
import { Database } from '@nocobase/database'; import { Database } from '@nocobase/database';
import { prepareApp } from './prepare'; import { prepareApp } from './prepare';
import UsersPlugin from '@nocobase/plugin-users';
describe('write role to acl', () => { describe('write role to acl', () => {
let app: MockServer; let app: MockServer;
@ -32,13 +31,7 @@ describe('write role to acl', () => {
}, },
}); });
const userPlugin = app.getPlugin('users') as UsersPlugin; const agent = app.agent().login(user);
const agent = app.agent().auth(
userPlugin.jwtService.sign({
userId: user.get('id'),
}),
{ type: 'bearer' },
);
// @ts-ignore // @ts-ignore
const response = await agent.resource('roles').check(); const response = await agent.resource('roles').check();

View File

@ -434,7 +434,7 @@ export class PluginACL extends Plugin {
}); });
}); });
this.app.resourcer.use(setCurrentRole, { tag: 'setCurrentRole', before: 'acl', after: 'parseToken' }); this.app.resourcer.use(setCurrentRole, { tag: 'setCurrentRole', before: 'acl', after: 'auth' });
this.app.acl.allow('users', 'setDefaultRole', 'loggedIn'); this.app.acl.allow('users', 'setDefaultRole', 'loggedIn');
this.app.acl.allow('roles', 'check', 'loggedIn'); this.app.acl.allow('roles', 'check', 'loggedIn');

View 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
View File

@ -0,0 +1,3 @@
// @ts-nocheck
export * from './lib/client';
export { default } from './lib/client';

65
packages/plugins/auth/client.js Executable file
View 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];
},
});
});

View 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
View File

@ -0,0 +1,3 @@
// @ts-nocheck
export * from './lib/server';
export { default } from './lib/server';

65
packages/plugins/auth/server.js Executable file
View 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];
},
});
});

View 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,
},
},
},
},
},
}}
/>
);
};

View 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 }} />;
};

View 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 }} />;
};

View 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>
);
};

View File

@ -0,0 +1,7 @@
import { useTranslation } from 'react-i18next';
export const NAMESPACE = 'auth';
export function useAuthTranslation() {
return useTranslation(NAMESPACE);
}

View 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;

View 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>
);
};

View 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;
});

View 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;
};

View File

@ -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() }}',
},
},
},
},
},
},
},
},
};

View File

@ -0,0 +1 @@
export { default, AuthModel } from './server';

View File

@ -0,0 +1,4 @@
export const presetAuthType = 'Email/Password';
export const presetAuthenticator = 'basic';
export const namespace = require('../package.json').name;

View 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);
});
});
});

View 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();
},
};

View 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();
},
};

View 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;
}
}

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,2 @@
export { default } from './plugin';
export { AuthModel } from './model/authenticator';

View 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',
};

View 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';

View File

@ -0,0 +1,4 @@
export default {
'Please fill in your email address': 'メールアドレスを入力してください',
'The password is incorrect, please re-enter': 'パスワードが正しくありません。再度入力してください。',
};

View 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',
};

View 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': '请至少保留并启用一个认证器',
};

View File

@ -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() {}
}

View File

@ -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() {}
}

View 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);
}
}

View 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;

View File

@ -2,17 +2,19 @@
"name": "@nocobase/plugin-oidc", "name": "@nocobase/plugin-oidc",
"displayName": "OpenID Connect", "displayName": "OpenID Connect",
"displayName.zh-CN": "OpenID Connect", "displayName.zh-CN": "OpenID Connect",
"description": " provide OIDC authentication and authorization functionality for applications or authentication systems.", "description": "Provides OIDC authentication and authorization functionality for applications or authentication systems.",
"description.zh-CN": "为应用程序或身份验证系统提供OIDC认证和授权功能。", "description.zh-CN": "为应用程序或身份验证系统提供OIDC认证和授权功能。",
"version": "0.9.4-alpha.2", "version": "0.9.4-alpha.2",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"main": "./lib/index.js", "main": "./lib/index.js",
"types": "./lib/index.d.ts", "types": "./lib/index.d.ts",
"devDependencies": { "devDependencies": {
"@nocobase/auth": "0.9.4-alpha.2",
"@nocobase/server": "0.9.4-alpha.2", "@nocobase/server": "0.9.4-alpha.2",
"@nocobase/test": "0.9.4-alpha.2" "@nocobase/test": "0.9.4-alpha.2"
}, },
"dependencies": { "dependencies": {
"openid-client": "^5.3.0" "nanoid": "3.3.4",
"openid-client": "^5.4.2"
} }
} }

View File

@ -1,6 +1,6 @@
import { LoginOutlined } from '@ant-design/icons'; import { LoginOutlined } from '@ant-design/icons';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { useAPIClient, useRedirect, useRequest } from '@nocobase/client'; import { Authenticator, useAPIClient, useRedirect } from '@nocobase/client';
import { useMemoizedFn } from 'ahooks'; import { useMemoizedFn } from 'ahooks';
import { Button, Space } from 'antd'; import { Button, Space } from 'antd';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
@ -10,25 +10,20 @@ export interface OIDCProvider {
title: string; title: string;
} }
export const OIDCList = () => { export const OIDCButton = (props: { authenticator: Authenticator }) => {
const [windowHandler, setWindowHandler] = useState<Window | undefined>(); const [windowHandler, setWindowHandler] = useState<Window | undefined>();
const api = useAPIClient(); const api = useAPIClient();
const redirect = useRedirect(); const redirect = useRedirect();
const { data, loading } = useRequest({
resource: 'oidc',
action: 'getEnabledProviders',
});
/** /**
* *
*/ */
const handleOpen = useMemoizedFn(async (item: OIDCProvider) => { const handleOpen = async (name: string) => {
const response = await api.request({ const response = await api.request({
method: 'post', method: 'post',
url: 'oidc:getAuthUrl', url: 'oidc:getAuthUrl',
data: { headers: {
clientId: item.clientId, 'X-Authenticator': name,
}, },
}); });
@ -44,16 +39,24 @@ export const OIDCList = () => {
); );
setWindowHandler(win); setWindowHandler(win);
}); };
/** /**
* *
*/ */
const handleOIDCLogin = useMemoizedFn(async (event: MessageEvent) => { const handleOIDCLogin = useMemoizedFn(async (event: MessageEvent) => {
await api.auth.signIn(event.data, 'oidc'); const { state } = event.data;
windowHandler.close(); const search = new URLSearchParams(state);
setWindowHandler(undefined); const authenticator = search.get('name');
redirect(); try {
await api.auth.signIn(event.data, authenticator);
redirect();
} catch (err) {
console.error(err);
} finally {
windowHandler.close();
setWindowHandler(undefined);
}
}); });
/** /**
@ -61,12 +64,14 @@ export const OIDCList = () => {
*/ */
useEffect(() => { useEffect(() => {
if (!windowHandler) return; if (!windowHandler) return;
window.addEventListener('message', handleOIDCLogin); window.addEventListener('message', handleOIDCLogin);
return () => { return () => {
window.removeEventListener('message', handleOIDCLogin); window.removeEventListener('message', handleOIDCLogin);
}; };
}, [windowHandler]); }, [windowHandler, handleOIDCLogin]);
const authenticator = props.authenticator;
return ( return (
<Space <Space
direction="vertical" direction="vertical"
@ -74,11 +79,9 @@ export const OIDCList = () => {
display: flex; display: flex;
`} `}
> >
{data?.data?.map?.((item: OIDCProvider) => ( <Button shape="round" block icon={<LoginOutlined />} onClick={() => handleOpen(authenticator.name)}>
<Button shape="round" block key={item.clientId} icon={<LoginOutlined />} onClick={() => handleOpen(item)}> {authenticator.title}
{item.title} </Button>
</Button>
))}
</Space> </Space>
); );
}; };

View File

@ -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>
);
};

View 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} />;
};

View File

@ -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' },
);

View File

@ -1,38 +1,15 @@
import { PluginManagerContext, SettingsCenterProvider, SigninPageExtensionProvider } from '@nocobase/client'; import { OptionsComponentProvider, SigninPageExtensionProvider } from '@nocobase/client';
import React, { useContext } from 'react'; import React from 'react';
import { useOidcTranslation } from './locale'; import { OIDCButton } from './OIDCButton';
import { OIDCList } from './OIDCList'; import { authType } from '../constants';
import { OIDCPanel } from './OIDCPanel'; import { Options } from './Options';
export default function (props) { export default function (props) {
const { t } = useOidcTranslation();
const ctx = useContext(PluginManagerContext);
return ( return (
<SigninPageExtensionProvider component={OIDCList}> <SigninPageExtensionProvider component={OIDCButton} authType={authType}>
<SettingsCenterProvider <OptionsComponentProvider authType={authType} component={Options}>
settings={{ {props.children}
oidc: { </OptionsComponentProvider>
title: t('OIDC manager'),
icon: 'FileOutlined',
tabs: {
providers: {
title: t('OIDC Providers'),
component: OIDCPanel,
},
},
},
}}
>
<PluginManagerContext.Provider
value={{
components: {
...ctx?.components,
},
}}
>
{props.children}
</PluginManagerContext.Provider>
</SettingsCenterProvider>
</SigninPageExtensionProvider> </SigninPageExtensionProvider>
); );
} }

View File

@ -1,25 +1,8 @@
export default { export default {
Issuer: 'Issuer',
Enable: '启用', Enable: '启用',
Actions: '操作', Actions: '操作',
Delete: '删除', Delete: '删除',
Edit: '编辑', Edit: '编辑',
'Button title': '登录按钮标题', Copied: '已复制',
'OIDC manager': 'OIDC 管理', 'Field Map': '字段映射',
'OIDC Providers': 'OIDC 身份提供者',
'Provider name': '名称',
'Client id': '客户端 id',
'Client secret': '客户端 secret',
'Openid configuration': '服务发现地址',
'Authorization endpoint': '授权端点',
'Access token endpoint': '令牌端点',
'JWKS endpoint': 'JWKS 公钥端点',
'Userinfo endpoint': '用户信息端点',
'Redirect url': '重定向地址',
'Logout endpoint': '登出端点',
'Id token sign alg': 'Id token 签名算法',
'Add provider': '添加',
'Edit provider': '编辑',
'Delete provider': '删除',
'Sign in button name, which will be displayed on the sign in page': '登录按钮名称,将在登录页中显示',
}; };

View File

@ -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}}',
},
},
},
},
},
},
},
},
},
},
},
};

View File

@ -0,0 +1,3 @@
export const authType = 'OIDC';
export const cookieName = 'nocobase_oidc';
export const namespace = require('../package.json').name;

View File

@ -1,3 +1 @@
export { default } from './server'; export { default } from './server';
export const namespace = require('../package.json').name;

View 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',
});
});

View File

@ -1,23 +1,22 @@
import { Context } from '@nocobase/actions'; import { Context, Next } from '@nocobase/actions';
import { createOIDCClient } from '../shared/createOIDCClient'; import { OIDCAuth } from '../oidc-auth';
import { OIDCProvider } from '../shared/types'; import { nanoid } from 'nanoid';
import { cookieName } from '../../constants';
export const getAuthUrl = async (ctx: Context, next) => { export const getAuthUrl = async (ctx: Context, next: Next) => {
const { const auth = ctx.auth as OIDCAuth;
params: { values }, const client = await auth.createOIDCClient();
} = ctx.action; const { scope } = auth.getOptions();
const providerRepo = ctx.db.getRepository('oidcProviders'); const token = nanoid(15);
const record = await providerRepo.findOne({ ctx.cookies.set(cookieName, token, {
filter: { httpOnly: true,
clientId: values.clientId, domain: ctx.hostname,
},
}); });
const provider: OIDCProvider = record.toJSON();
const client = await createOIDCClient(provider);
ctx.body = client.authorizationUrl({ ctx.body = client.authorizationUrl({
nonce: ctx.OIDC_NONCE, response_type: 'code',
scope: 'openid profile', scope: scope || 'openid email profile',
redirect_uri: auth.getRedirectUri(),
state: `token=${token}&name=${ctx.headers['x-authenticator']}`,
}); });
return next(); return next();

View File

@ -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();
};

View File

@ -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();
};

View File

@ -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;

View 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,
});
}
}

View File

@ -1,27 +1,18 @@
import { InstallOptions, Plugin } from '@nocobase/server'; import { InstallOptions, Plugin } from '@nocobase/server';
import { generators } from 'openid-client';
import { resolve } from 'path';
import { getAuthUrl } from './actions/getAuthUrl'; import { getAuthUrl } from './actions/getAuthUrl';
import { getEnabledProviders } from './actions/getEnabledProviders';
import { redirect } from './actions/redirect'; import { redirect } from './actions/redirect';
import { oidc } from './authenticators/oidc'; import { authType } from '../constants';
import { OIDCAuth } from './oidc-auth';
export class OidcPlugin extends Plugin { export class OidcPlugin extends Plugin {
#OIDC_NONCE = null;
afterAdd() {} afterAdd() {}
beforeLoad() {} beforeLoad() {}
async load() { async load() {
// 导入 collection this.app.authManager.registerTypes(authType, {
await this.importCollections(resolve(__dirname, './collections')); auth: OIDCAuth,
});
// 获取 User 插件
const userPlugin = this.app.getPlugin<any>('users');
// 注册 OIDC 验证器
userPlugin.authenticators.register('oidc', oidc);
// 注册接口 // 注册接口
this.app.resource({ this.app.resource({
@ -29,27 +20,10 @@ export class OidcPlugin extends Plugin {
actions: { actions: {
getAuthUrl, getAuthUrl,
redirect, redirect,
getEnabledProviders,
}, },
}); });
// 注册中间件,处理 nonce 值
this.app.use(async (ctx, next) => {
if (ctx.url.startsWith('/api/users:signin?authenticator=oidc')) {
ctx.OIDC_NONCE = this.#OIDC_NONCE;
}
if (ctx.url.startsWith('/api/oidc:getAuthUrl')) {
ctx.OIDC_NONCE = this.#OIDC_NONCE = generators.nonce();
}
await next();
});
this.app.acl.allow('oidc', '*', 'public'); this.app.acl.allow('oidc', '*', 'public');
this.app.acl.registerSnippet({
name: `pm.${this.name}.providers`,
actions: ['oidcProviders:*'],
});
} }
async install(options?: InstallOptions) {} async install(options?: InstallOptions) {}

Some files were not shown because too many files have changed in this diff Show More