mirror of
https://gitee.com/nocobase/nocobase.git
synced 2024-12-02 12:18:15 +08:00
feat: support token blacklist (#2168)
* feat: support token blacklist, Close T-799 * feat: clean * fix: possible token does not exist * fix: update * feat: update * feat: add node-cron to delete expired token * fix: findOrCreate not work and add test case * test: add token-blacklist tests * feat: add test cases for blacklist in authManager * test: update better * fix: should hidden token field * test: clean * test: clean * fix: should stop cron in afterStop * refactor: move delete expired token in token blacklist service * feat: remove plugin disable/enable logic * fix: clean * test: revert * fix: cron typo
This commit is contained in:
parent
207ad61c63
commit
25a3a8affa
@ -1,6 +1,7 @@
|
||||
import { Context } from '@nocobase/actions';
|
||||
import { Auth, AuthManager } from '@nocobase/auth';
|
||||
import { Model } from '@nocobase/database';
|
||||
import Database, { Model } from '@nocobase/database';
|
||||
import { MockServer, mockServer } from '@nocobase/test';
|
||||
|
||||
class MockStorer {
|
||||
elements: Map<string, any> = new Map();
|
||||
@ -47,4 +48,69 @@ describe('auth-manager', () => {
|
||||
const authenticator = await authManager.get('basic-test', {} as Context);
|
||||
expect(authenticator).toBeInstanceOf(BasicAuth);
|
||||
});
|
||||
|
||||
describe('middleware', () => {
|
||||
let app: MockServer;
|
||||
let db: Database;
|
||||
let agent;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = mockServer({
|
||||
registerActions: true,
|
||||
acl: true,
|
||||
plugins: ['users', 'auth', 'acl'],
|
||||
});
|
||||
|
||||
// app.plugin(ApiKeysPlugin);
|
||||
await app.loadAndInstall({ clean: true });
|
||||
db = app.db;
|
||||
agent = app.agent();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
describe('blacklist', () => {
|
||||
const hasFn = jest.fn();
|
||||
const addFn = jest.fn();
|
||||
beforeEach(async () => {
|
||||
await agent.login(1);
|
||||
app.authManager.setTokenBlacklistService({
|
||||
has: hasFn,
|
||||
add: addFn,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
hasFn.mockReset();
|
||||
addFn.mockReset();
|
||||
});
|
||||
|
||||
it('basic', async () => {
|
||||
const res = await agent.resource('auth').check();
|
||||
const token = res.request.header['Authorization'].replace('Bearer ', '');
|
||||
expect(res.status).toBe(200);
|
||||
expect(hasFn).toHaveBeenCalledWith(token);
|
||||
});
|
||||
|
||||
it('signOut should add token to blacklist', async () => {
|
||||
// signOut will add token
|
||||
const res = await agent.resource('auth').signOut();
|
||||
const token = res.request.header['Authorization'].replace('Bearer ', '');
|
||||
expect(addFn).toHaveBeenCalledWith({
|
||||
token,
|
||||
// Date or String is ok
|
||||
expiration: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw 401 when token in blacklist', async () => {
|
||||
hasFn.mockImplementation(() => true);
|
||||
const res = await agent.resource('auth').check();
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.text).toContain('token is not available');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -3,6 +3,7 @@ import { Model } from '@nocobase/database';
|
||||
import { Registry } from '@nocobase/utils';
|
||||
import { Auth, AuthExtend } from './auth';
|
||||
import { JwtOptions, JwtService } from './base/jwt-service';
|
||||
import { ITokenBlacklistService } from './base/token-blacklist-service';
|
||||
|
||||
type Storer = {
|
||||
get: (name: string) => Promise<Model>;
|
||||
@ -34,6 +35,10 @@ export class AuthManager {
|
||||
this.storer = storer;
|
||||
}
|
||||
|
||||
setTokenBlacklistService(service: ITokenBlacklistService) {
|
||||
this.jwt.blacklist = service;
|
||||
}
|
||||
|
||||
/**
|
||||
* registerTypes
|
||||
* @description Add a new authenticate type and the corresponding authenticator.
|
||||
@ -81,6 +86,11 @@ export class AuthManager {
|
||||
*/
|
||||
middleware() {
|
||||
return async (ctx: Context & { auth: Auth }, next: Next) => {
|
||||
const token = ctx.getBearerToken();
|
||||
if (token && (await ctx.app.authManager.jwt.blacklist.has(token))) {
|
||||
return ctx.throw(401, ctx.t('token is not available'));
|
||||
}
|
||||
|
||||
const name = ctx.get(this.options.authKey) || this.options.default;
|
||||
let authenticator: Auth;
|
||||
try {
|
||||
|
@ -81,4 +81,12 @@ export class BaseAuth extends Auth {
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
async signOut(): Promise<any> {
|
||||
const token = this.ctx.getBearerToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
return await this.jwt.block(token);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import jwt, { SignOptions } from 'jsonwebtoken';
|
||||
import { ITokenBlacklistService } from './token-blacklist-service';
|
||||
|
||||
export interface JwtOptions {
|
||||
secret: string;
|
||||
@ -20,6 +21,8 @@ export class JwtService {
|
||||
};
|
||||
}
|
||||
|
||||
public blacklist: ITokenBlacklistService;
|
||||
|
||||
private expiresIn() {
|
||||
return this.options.expiresIn;
|
||||
}
|
||||
@ -43,4 +46,19 @@ export class JwtService {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Block a token so that this token can no longer be used
|
||||
*/
|
||||
async block(token: string) {
|
||||
if (!this.blacklist) {
|
||||
return null;
|
||||
}
|
||||
const { exp } = await this.decode(token);
|
||||
|
||||
return this.blacklist.add({
|
||||
token,
|
||||
expiration: new Date(exp * 1000).toString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
4
packages/core/auth/src/base/token-blacklist-service.ts
Normal file
4
packages/core/auth/src/base/token-blacklist-service.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface ITokenBlacklistService {
|
||||
has(token: string): Promise<boolean>;
|
||||
add(values: { token: string; expiration: string | Date }): Promise<any>;
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
export * from './auth-manager';
|
||||
export * from './auth';
|
||||
export * from './base/auth';
|
||||
export * from './actions';
|
||||
export * from './auth';
|
||||
export * from './auth-manager';
|
||||
export * from './base/auth';
|
||||
export * from './base/token-blacklist-service';
|
||||
|
@ -99,6 +99,9 @@ export class MockServer extends Application {
|
||||
userId: typeof userOrId === 'number' ? userOrId : userOrId?.id,
|
||||
},
|
||||
process.env.APP_KEY,
|
||||
{
|
||||
expiresIn: '1d',
|
||||
},
|
||||
),
|
||||
{ type: 'bearer' },
|
||||
)
|
||||
|
@ -78,5 +78,10 @@ export default {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'token',
|
||||
type: 'string',
|
||||
hidden: true,
|
||||
},
|
||||
],
|
||||
} as CollectionOptions;
|
||||
|
@ -15,7 +15,6 @@ describe('actions', () => {
|
||||
plugins: ['users', 'auth', 'api-keys', 'acl'],
|
||||
});
|
||||
|
||||
// app.plugin(ApiKeysPlugin);
|
||||
await app.loadAndInstall({ clean: true });
|
||||
db = app.db;
|
||||
repo = db.getRepository('apiKeys');
|
||||
@ -151,7 +150,7 @@ describe('actions', () => {
|
||||
expect(res.body.data.length).toBe(1);
|
||||
const data = res.body.data[0];
|
||||
await resource.destroy({
|
||||
id: data.id,
|
||||
filterByTk: data.id,
|
||||
});
|
||||
expect((await resource.list()).body.data.length).toBe(0);
|
||||
});
|
||||
@ -162,10 +161,21 @@ describe('actions', () => {
|
||||
const data = res.body.data[0];
|
||||
await agent.login(testUser);
|
||||
await resource.destroy({
|
||||
id: data.id,
|
||||
filterByTk: data.id,
|
||||
});
|
||||
await agent.login(user);
|
||||
expect((await resource.list()).body.data.length).toBe(1);
|
||||
});
|
||||
|
||||
it('The token should not work after removing the api key', async () => {
|
||||
const res = await resource.list();
|
||||
expect(res.body.data.length).toBe(1);
|
||||
const data = res.body.data[0];
|
||||
await resource.destroy({
|
||||
filterByTk: data.id,
|
||||
});
|
||||
const response = await agent.set('Authorization', `Bearer ${result.token}`).resource('auth').check();
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -18,15 +18,32 @@ export async function create(ctx: Context, next: Next) {
|
||||
throw ctx.throw(400, ctx.t('Role not found'));
|
||||
}
|
||||
|
||||
const token = ctx.app.authManager.jwt.sign(
|
||||
{ userId: ctx.auth.user.id, roleName: role.name },
|
||||
{ expiresIn: values.expiresIn },
|
||||
);
|
||||
ctx.action.mergeParams({
|
||||
values: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
return actions.create(ctx, async () => {
|
||||
const token = ctx.app.authManager.jwt.sign(
|
||||
{ userId: ctx.auth.user.id, roleName: role.name },
|
||||
{ expiresIn: values.expiresIn },
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
token,
|
||||
};
|
||||
await next();
|
||||
});
|
||||
}
|
||||
|
||||
export async function destroy(ctx: Context, next: Next) {
|
||||
const repo = ctx.db.getRepository(ctx.action.resourceName);
|
||||
const { filterByTk } = ctx.action.params;
|
||||
|
||||
const data = await repo.findById(filterByTk);
|
||||
const token = data?.get('token');
|
||||
if (token) {
|
||||
await ctx.app.authManager.jwt.block(token);
|
||||
}
|
||||
|
||||
return actions.destroy(ctx, next);
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Plugin } from '@nocobase/server';
|
||||
import { resolve } from 'path';
|
||||
import { NAMESPACE } from '../constants';
|
||||
import { create } from './actions/api-keys';
|
||||
import { create, destroy } from './actions/api-keys';
|
||||
import { enUS, zhCN } from './locale';
|
||||
|
||||
export interface ApiKeysPluginConfig {
|
||||
@ -22,6 +22,7 @@ export default class ApiKeysPlugin extends Plugin<ApiKeysPluginConfig> {
|
||||
name: this.resourceName,
|
||||
actions: {
|
||||
create,
|
||||
destroy,
|
||||
},
|
||||
only: ['list', 'create', 'destroy'],
|
||||
});
|
||||
|
@ -3,11 +3,15 @@
|
||||
"version": "0.10.0-alpha.5",
|
||||
"main": "lib/server/index.js",
|
||||
"devDependencies": {
|
||||
"@nocobase/test": "0.10.0-alpha.5",
|
||||
"@types/cron": "^2.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nocobase/actions": "0.10.0-alpha.5",
|
||||
"@nocobase/client": "0.10.0-alpha.5",
|
||||
"@nocobase/database": "0.10.0-alpha.5",
|
||||
"@nocobase/server": "0.10.0-alpha.5",
|
||||
"@nocobase/test": "0.10.0-alpha.5"
|
||||
"cron": "^2.3.1"
|
||||
},
|
||||
"displayName": "Authentication",
|
||||
"displayName.zh-CN": "用户认证",
|
||||
|
@ -0,0 +1,73 @@
|
||||
import Database, { Repository } from '@nocobase/database';
|
||||
import { MockServer, mockServer } from '@nocobase/test';
|
||||
import { TokenBlacklistService } from '../token-blacklist';
|
||||
|
||||
describe('token-blacklist', () => {
|
||||
let app: MockServer;
|
||||
let db: Database;
|
||||
let repo: Repository;
|
||||
let tokenBlacklist: TokenBlacklistService;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = mockServer({
|
||||
plugins: ['auth'],
|
||||
});
|
||||
await app.loadAndInstall({ clean: true });
|
||||
db = app.db;
|
||||
repo = db.getRepository('tokenBlacklist');
|
||||
tokenBlacklist = new TokenBlacklistService(app.getPlugin('auth'));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await repo.destroy({
|
||||
truncate: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('add and has correctly', async () => {
|
||||
await tokenBlacklist.add({
|
||||
token: 'test',
|
||||
expiration: new Date(),
|
||||
});
|
||||
|
||||
await tokenBlacklist.add({
|
||||
token: 'test1',
|
||||
expiration: new Date(),
|
||||
});
|
||||
|
||||
expect(tokenBlacklist.has('test')).toBeTruthy();
|
||||
expect(tokenBlacklist.has('test1')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('add same token correctly', async () => {
|
||||
await tokenBlacklist.add({
|
||||
token: 'test',
|
||||
expiration: new Date(),
|
||||
});
|
||||
|
||||
await tokenBlacklist.add({
|
||||
token: 'test',
|
||||
expiration: new Date(),
|
||||
});
|
||||
|
||||
expect(tokenBlacklist.has('test')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('delete expired token correctly', async () => {
|
||||
await tokenBlacklist.add({
|
||||
token: 'should be deleted',
|
||||
expiration: new Date('2020-01-01'),
|
||||
});
|
||||
await tokenBlacklist.add({
|
||||
token: 'should not be deleted',
|
||||
expiration: new Date('2100-01-01'),
|
||||
});
|
||||
await tokenBlacklist.deleteByExpiration();
|
||||
expect(await tokenBlacklist.has('should be deleted')).not.toBeTruthy();
|
||||
expect(await tokenBlacklist.has('should not be deleted')).toBeTruthy();
|
||||
});
|
||||
});
|
@ -0,0 +1,19 @@
|
||||
import { CollectionOptions } from '@nocobase/client';
|
||||
|
||||
export default {
|
||||
namespace: 'auth.token-black',
|
||||
duplicator: 'optional',
|
||||
name: 'tokenBlacklist',
|
||||
model: 'TokenBlacklistModel',
|
||||
fields: [
|
||||
{
|
||||
type: 'string',
|
||||
name: 'token',
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
type: 'date',
|
||||
name: 'expiration',
|
||||
},
|
||||
],
|
||||
} as CollectionOptions;
|
@ -1,17 +1,16 @@
|
||||
import { Model } from '@nocobase/database';
|
||||
import { InstallOptions, Plugin } from '@nocobase/server';
|
||||
import { resolve } from 'path';
|
||||
import { BasicAuth } from './basic-auth';
|
||||
import { presetAuthType, presetAuthenticator } from '../preset';
|
||||
import { namespace, presetAuthenticator, presetAuthType } from '../preset';
|
||||
import authActions from './actions/auth';
|
||||
import authenticatorsActions from './actions/authenticators';
|
||||
import { BasicAuth } from './basic-auth';
|
||||
import { enUS, zhCN } from './locale';
|
||||
import { namespace } from '../preset';
|
||||
import { AuthModel } from './model/authenticator';
|
||||
import { Model } from '@nocobase/database';
|
||||
import { TokenBlacklistService } from './token-blacklist';
|
||||
|
||||
export class AuthPlugin extends Plugin {
|
||||
afterAdd() {}
|
||||
|
||||
async beforeLoad() {
|
||||
this.app.i18n.addResources('zh-CN', namespace, zhCN);
|
||||
this.app.i18n.addResources('en-US', namespace, enUS);
|
||||
@ -40,6 +39,12 @@ export class AuthPlugin extends Plugin {
|
||||
return authenticator || authenticators[0];
|
||||
},
|
||||
});
|
||||
|
||||
if (!this.app.authManager.jwt.blacklist) {
|
||||
// If blacklist service is not set, should configure default blacklist service
|
||||
this.app.authManager.setTokenBlacklistService(new TokenBlacklistService(this));
|
||||
}
|
||||
|
||||
this.app.authManager.registerTypes(presetAuthType, {
|
||||
auth: BasicAuth,
|
||||
});
|
||||
@ -81,11 +86,6 @@ export class AuthPlugin extends Plugin {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async afterEnable() {}
|
||||
|
||||
async afterDisable() {}
|
||||
|
||||
async remove() {}
|
||||
}
|
||||
|
||||
|
66
packages/plugins/auth/src/server/token-blacklist.ts
Normal file
66
packages/plugins/auth/src/server/token-blacklist.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { ITokenBlacklistService } from '@nocobase/auth';
|
||||
import { Repository } from '@nocobase/database';
|
||||
import { CronJob } from 'cron';
|
||||
import AuthPlugin from './plugin';
|
||||
|
||||
export class TokenBlacklistService implements ITokenBlacklistService {
|
||||
repo: Repository;
|
||||
cronJob: CronJob;
|
||||
|
||||
constructor(protected plugin: AuthPlugin) {
|
||||
this.repo = plugin.db.getRepository('tokenBlacklist');
|
||||
this.cronJob = this.createCronJob();
|
||||
}
|
||||
|
||||
get app() {
|
||||
return this.plugin.app;
|
||||
}
|
||||
|
||||
createCronJob() {
|
||||
const cronJob = new CronJob(
|
||||
// every day at 03:00
|
||||
'0 3 * * *', //
|
||||
async () => {
|
||||
this.app.logger.info(`${this.plugin.name}: Start delete expired blacklist token`);
|
||||
await this.deleteByExpiration();
|
||||
this.app.logger.info(`${this.plugin.name}: End delete expired blacklist token`);
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
this.app.once('beforeStart', () => {
|
||||
cronJob.start();
|
||||
});
|
||||
|
||||
this.app.once('beforeStop', () => {
|
||||
cronJob.stop();
|
||||
});
|
||||
|
||||
return cronJob;
|
||||
}
|
||||
|
||||
async has(token: string) {
|
||||
return !!(await this.repo.findOne({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
}));
|
||||
}
|
||||
async add(values) {
|
||||
return this.repo.model.findOrCreate({
|
||||
defaults: values,
|
||||
where: {
|
||||
token: values.token,
|
||||
},
|
||||
});
|
||||
}
|
||||
async deleteByExpiration() {
|
||||
return this.repo.destroy({
|
||||
filter: {
|
||||
expiration: {
|
||||
$dateNotAfter: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
34
yarn.lock
34
yarn.lock
@ -5467,9 +5467,10 @@
|
||||
version "1.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.6.2.tgz#bbe75f8c59e0b7077584920ce2cc76f8f354934d"
|
||||
|
||||
"@remix-run/router@1.6.3":
|
||||
version "1.6.3"
|
||||
resolved "https://registry.npmmirror.com/@remix-run/router/-/router-1.6.3.tgz#8205baf6e17ef93be35bf62c37d2d594e9be0dad"
|
||||
"@remix-run/router@1.7.1":
|
||||
version "1.7.1"
|
||||
resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.7.1.tgz#fea7ac35ae4014637c130011f59428f618730498"
|
||||
integrity sha512-bgVQM4ZJ2u2CM8k1ey70o1ePFXsEzYVZoWghh6WjM8p59jQ7HxzbHW4SbnWFG7V9ig9chLawQxDTZ3xzOF8MkQ==
|
||||
|
||||
"@restart/hooks@^0.3.25":
|
||||
version "0.3.27"
|
||||
@ -6013,6 +6014,14 @@
|
||||
"@types/keygrip" "*"
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/cron@^2.0.1":
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/cron/-/cron-2.0.1.tgz#d8bf7a24475f64197c7ac868c362b41be596e5f8"
|
||||
integrity sha512-WHa/1rtNtD2Q/H0+YTTZoty+/5rcE66iAFX2IY+JuUoOACsevYyFkSYu/2vdw+G5LrmO7Lxowrqm0av4k3qWNQ==
|
||||
dependencies:
|
||||
"@types/luxon" "*"
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/d3-array@*":
|
||||
version "3.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.0.5.tgz#857c1afffd3f51319bbc5b301956aca68acaa7b8"
|
||||
@ -6405,6 +6414,11 @@
|
||||
version "4.14.195"
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.195.tgz#bafc975b252eb6cea78882ce8a7b6bf22a6de632"
|
||||
|
||||
"@types/luxon@*":
|
||||
version "3.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-3.3.0.tgz#a61043a62c0a72696c73a0a305c544c96501e006"
|
||||
integrity sha512-uKRI5QORDnrGFYgcdAVnHvEIvEZ8noTpP/Bg+HeUzZghwinDlIS87DEenV5r1YoOF9G4x600YsUXLWZ19rmTmg==
|
||||
|
||||
"@types/markdown-it-highlightjs@3.3.1":
|
||||
version "3.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/markdown-it-highlightjs/-/markdown-it-highlightjs-3.3.1.tgz#1e051211e7754ba478449fea7faeab3d177ca892"
|
||||
@ -10116,6 +10130,13 @@ cron-parser@^4.6.0:
|
||||
dependencies:
|
||||
luxon "^3.2.1"
|
||||
|
||||
cron@^2.3.1:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/cron/-/cron-2.3.1.tgz#71caa566ffef60ada9183b8677df4afa9a214ad7"
|
||||
integrity sha512-1eRRlIT0UfIqauwbG9pkg3J6CX9A6My2ytJWqAXoK0T9oJnUZTzGBNPxao0zjodIbPgf8UQWjE62BMb9eVllSQ==
|
||||
dependencies:
|
||||
luxon "^3.2.1"
|
||||
|
||||
croner@~4.1.92:
|
||||
version "4.1.97"
|
||||
resolved "https://registry.yarnpkg.com/croner/-/croner-4.1.97.tgz#6e373dc7bb3026fab2deb0d82685feef20796766"
|
||||
@ -21521,10 +21542,11 @@ react-router-dom@6.3.0, react-router-dom@^6.11.2:
|
||||
react-router "6.11.2"
|
||||
|
||||
react-router@6.11.2, react-router@6.3.0, react-router@^6.11.2:
|
||||
version "6.12.1"
|
||||
resolved "https://registry.npmmirror.com/react-router/-/react-router-6.12.1.tgz#9e4126aa1139ec6b5d347e19576d5e940cd46362"
|
||||
version "6.14.1"
|
||||
resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.14.1.tgz#5e82bcdabf21add859dc04b1859f91066b3a5810"
|
||||
integrity sha512-U4PfgvG55LdvbQjg5Y9QRWyVxIdO1LlpYT7x+tMAxd9/vmiPuJhIwdxZuIQLN/9e3O4KFDHYfR9gzGeYMasW8g==
|
||||
dependencies:
|
||||
"@remix-run/router" "1.6.3"
|
||||
"@remix-run/router" "1.7.1"
|
||||
|
||||
react-side-effect@^2.1.0:
|
||||
version "2.1.2"
|
||||
|
Loading…
Reference in New Issue
Block a user