fix(auth): should set user token as invalid when changing password (#5212)

* fix(auth): should log user out when changing password

* fix: add passwordChangeTZ

* fix: clear local token when token is invalid

* fix: test

* fix: field name
This commit is contained in:
YANG QIA 2024-09-06 14:43:14 +08:00 committed by GitHub
parent 0a1ce687d7
commit bd942342b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 74 additions and 36 deletions

View File

@ -71,7 +71,7 @@ describe('middleware', () => {
hasFn.mockImplementation(() => true); hasFn.mockImplementation(() => true);
const res = await agent.resource('auth').check(); const res = await agent.resource('auth').check();
expect(res.status).toBe(401); expect(res.status).toBe(401);
expect(res.text).toContain('token is not available'); expect(res.text).toContain('Token is invalid');
}); });
}); });
}); });

View File

@ -109,7 +109,10 @@ export class AuthManager {
return async (ctx: Context & { auth: Auth }, next: Next) => { return async (ctx: Context & { auth: Auth }, next: Next) => {
const token = ctx.getBearerToken(); const token = ctx.getBearerToken();
if (token && (await ctx.app.authManager.jwt.blacklist?.has(token))) { if (token && (await ctx.app.authManager.jwt.blacklist?.has(token))) {
return ctx.throw(401, ctx.t('token is not available')); return ctx.throw(401, {
code: 'TOKEN_INVALID',
message: ctx.t('Token is invalid'),
});
} }
const name = ctx.get(this.options.authKey) || this.options.default; const name = ctx.get(this.options.authKey) || this.options.default;

View File

@ -69,14 +69,14 @@ export class BaseAuth extends Auth {
return null; return null;
} }
try { try {
const { userId, roleName } = await this.jwt.decode(token); const { userId, roleName, iat, temp } = await this.jwt.decode(token);
if (roleName) { if (roleName) {
this.ctx.headers['x-role'] = roleName; this.ctx.headers['x-role'] = roleName;
} }
const cache = this.ctx.cache as Cache; const cache = this.ctx.cache as Cache;
return await cache.wrap(this.getCacheKey(userId), () => const user = await cache.wrap(this.getCacheKey(userId), () =>
this.userRepository.findOne({ this.userRepository.findOne({
filter: { filter: {
id: userId, id: userId,
@ -84,6 +84,10 @@ export class BaseAuth extends Auth {
raw: true, raw: true,
}), }),
); );
if (temp && user.passwordChangeTz && iat * 1000 < user.passwordChangeTz) {
throw new Error('Token is invalid');
}
return user;
} catch (err) { } catch (err) {
this.ctx.logger.error(err, { method: 'check' }); this.ctx.logger.error(err, { method: 'check' });
return null; return null;
@ -106,6 +110,7 @@ export class BaseAuth extends Auth {
} }
const token = this.jwt.sign({ const token = this.jwt.sign({
userId: user.id, userId: user.id,
temp: true,
}); });
return { return {
user, user,
@ -119,7 +124,7 @@ export class BaseAuth extends Auth {
return; return;
} }
const { userId } = await this.jwt.decode(token); const { userId } = await this.jwt.decode(token);
await this.ctx.app.emitAsync('beforeSignOut', { userId }); await this.ctx.app.emitAsync('cache:del:roles', { userId });
await this.ctx.cache.del(this.getCacheKey(userId)); await this.ctx.cache.del(this.getCacheKey(userId));
return await this.jwt.block(token); return await this.jwt.block(token);
} }

View File

@ -98,6 +98,9 @@ export class APIClient extends APIClientSDK {
if (errs.find((error: { code?: string }) => error.code === 'ROLE_NOT_FOUND_ERR')) { if (errs.find((error: { code?: string }) => error.code === 'ROLE_NOT_FOUND_ERR')) {
this.auth.setRole(null); this.auth.setRole(null);
} }
if (errs.find((error: { code?: string }) => error.code === 'TOKEN_INVALID')) {
this.auth.setToken(null);
}
throw error; throw error;
}, },
); );
@ -119,7 +122,7 @@ export class APIClient extends APIClientSDK {
} }
return response; return response;
}, },
(error) => { async (error) => {
if (this.silence) { if (this.silence) {
throw error; throw error;
} }

View File

@ -14,6 +14,7 @@ import React, { useContext, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ActionContextProvider, DropdownVisibleContext, SchemaComponent, useActionContext } from '../'; import { ActionContextProvider, DropdownVisibleContext, SchemaComponent, useActionContext } from '../';
import { useAPIClient } from '../api-client'; import { useAPIClient } from '../api-client';
import { useNavigate } from 'react-router-dom';
const useCloseAction = () => { const useCloseAction = () => {
const { setVisible } = useActionContext(); const { setVisible } = useActionContext();
@ -29,6 +30,7 @@ const useCloseAction = () => {
}; };
const useSaveCurrentUserValues = () => { const useSaveCurrentUserValues = () => {
const navigate = useNavigate();
const { setVisible } = useActionContext(); const { setVisible } = useActionContext();
const form = useForm(); const form = useForm();
const api = useAPIClient(); const api = useAPIClient();
@ -40,6 +42,7 @@ const useSaveCurrentUserValues = () => {
}); });
await form.reset(); await form.reset();
setVisible(false); setVisible(false);
navigate('/signin');
}, },
}; };
}; };

View File

@ -7,9 +7,8 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import { getLoggerFilePath } from './config';
import { Logger, LoggerOptions } from './logger'; import { Logger, LoggerOptions } from './logger';
import { pick } from 'lodash'; import { pick, omit } from 'lodash';
const defaultRequestWhitelist = [ const defaultRequestWhitelist = [
'action', 'action',
'header.x-role', 'header.x-role',
@ -21,6 +20,12 @@ const defaultRequestWhitelist = [
'referer', 'referer',
]; ];
const defaultResponseWhitelist = ['status']; const defaultResponseWhitelist = ['status'];
const defaultActionBlackList = [
'params.values.password',
'params.values.confirmPassword',
'params.values.oldPassword',
'params.values.newPassword',
];
export interface RequestLoggerOptions extends LoggerOptions { export interface RequestLoggerOptions extends LoggerOptions {
skip?: (ctx?: any) => Promise<boolean>; skip?: (ctx?: any) => Promise<boolean>;
@ -60,7 +65,7 @@ export const requestLogger = (appName: string, requestLogger: Logger, options?:
message: `response ${ctx.url}`, message: `response ${ctx.url}`,
...requestInfo, ...requestInfo,
res: pick(ctx.response.toJSON(), options?.responseWhitelist || defaultResponseWhitelist), res: pick(ctx.response.toJSON(), options?.responseWhitelist || defaultResponseWhitelist),
action: ctx.action?.toJSON?.(), action: omit(ctx.action?.toJSON?.(), defaultActionBlackList),
userId: ctx.auth?.user?.id, userId: ctx.auth?.user?.id,
status: ctx.status, status: ctx.status,
cost, cost,

View File

@ -45,6 +45,7 @@ export class Locale {
name: 'locale', name: 'locale',
prefix: 'locale', prefix: 'locale',
store: 'memory', store: 'memory',
max: 2000
}); });
await this.get(this.defaultLang); await this.get(this.defaultLang);

View File

@ -123,6 +123,7 @@ export class MockServer extends Application {
jwt.sign( jwt.sign(
{ {
userId: typeof userOrId === 'number' ? userOrId : userOrId?.id, userId: typeof userOrId === 'number' ? userOrId : userOrId?.id,
temp: true,
}, },
process.env.APP_KEY, process.env.APP_KEY,
{ {

View File

@ -408,7 +408,7 @@ export class PluginACLServer extends Plugin {
}); });
}); });
this.app.on('beforeSignOut', ({ userId }) => { this.app.on('cache:del:roles', ({ userId }) => {
this.app.cache.del(`roles:${userId}`); this.app.cache.del(`roles:${userId}`);
}); });
this.app.resourcer.use(setCurrentRole, { tag: 'setCurrentRole', before: 'acl', after: 'auth' }); this.app.resourcer.use(setCurrentRole, { tag: 'setCurrentRole', before: 'acl', after: 'auth' });

View File

@ -102,6 +102,7 @@ describe('actions', () => {
}); });
afterEach(async () => { afterEach(async () => {
await app.db.clean({ drop: true });
await app.destroy(); await app.destroy();
}); });
@ -273,5 +274,28 @@ describe('actions', () => {
expect(res.statusCode).toEqual(400); expect(res.statusCode).toEqual(400);
expect(res.error.text).toBe('Please enter a password'); expect(res.error.text).toBe('Please enter a password');
}); });
it('should sign user out when changing password', async () => {
const userRepo = db.getRepository('users');
const user = await userRepo.create({
values: {
username: 'test',
password: '12345',
},
});
const userAgent = await agent.login(user);
const res = await userAgent.post('/auth:check').set({ 'X-Authenticator': 'basic' }).send();
expect(res.statusCode).toEqual(200);
expect(res.body.data.id).toBeDefined();
const res2 = await userAgent.post('/auth:changePassword').set({ 'X-Authenticator': 'basic' }).send({
oldPassword: '12345',
newPassword: '123456',
confirmPassword: '123456',
});
expect(res2.statusCode).toEqual(200);
const res3 = await userAgent.post('/auth:check').set({ 'X-Authenticator': 'basic' }).send();
expect(res3.statusCode).toEqual(200);
expect(res3.body.data.id).toBeUndefined();
});
}); });
}); });

View File

@ -29,15 +29,6 @@ export class PluginAuthServer extends Plugin {
} }
async load() { async load() {
// Set up database
await this.importCollections(resolve(__dirname, 'collections'));
this.db.addMigrations({
namespace: 'auth',
directory: resolve(__dirname, 'migrations'),
context: {
plugin: this,
},
});
this.cache = await this.app.cacheManager.createCache({ this.cache = await this.app.cacheManager.createCache({
name: 'auth', name: 'auth',
prefix: 'auth', prefix: 'auth',
@ -85,6 +76,9 @@ export class PluginAuthServer extends Plugin {
const cache = this.app.cache as Cache; const cache = this.app.cache as Cache;
await cache.del(`auth:${user.id}`); await cache.del(`auth:${user.id}`);
}); });
this.app.on('cache:del:auth', async ({ userId }) => {
await this.cache.del(`auth:${userId}`);
});
} }
async install(options?: InstallOptions) { async install(options?: InstallOptions) {

View File

@ -91,6 +91,10 @@ export default defineCollection({
'x-component': 'Password', 'x-component': 'Password',
}, },
}, },
{
name: 'passwordChangeTz',
type: 'bigInt',
},
{ {
type: 'string', type: 'string',
name: 'appLang', name: 'appLang',

View File

@ -7,11 +7,9 @@
* For more information, please refer to: https://www.nocobase.com/agreement. * For more information, please refer to: https://www.nocobase.com/agreement.
*/ */
import { Collection, Op } from '@nocobase/database'; import { Collection, Model, Op } from '@nocobase/database';
import { Plugin } from '@nocobase/server'; import { Plugin } from '@nocobase/server';
import { parse } from '@nocobase/utils'; import { parse } from '@nocobase/utils';
import { resolve } from 'path';
import { Cache } from '@nocobase/cache';
import * as actions from './actions/users'; import * as actions from './actions/users';
import { UserModel } from './models/UserModel'; import { UserModel } from './models/UserModel';
import PluginUserDataSyncServer from '@nocobase/plugin-user-data-sync'; import PluginUserDataSyncServer from '@nocobase/plugin-user-data-sync';
@ -155,19 +153,9 @@ export default class PluginUsersServer extends Plugin {
} }
async load() { async load() {
await this.importCollections(resolve(__dirname, 'collections')); this.app.resourceManager.use(async (ctx, next) => {
this.db.addMigrations({
namespace: 'users',
directory: resolve(__dirname, 'migrations'),
context: {
plugin: this,
},
});
this.app.resourcer.use(async (ctx, next) => {
await next(); await next();
const { associatedName, resourceName, actionName, values } = ctx.action.params; const { associatedName, resourceName, actionName, values } = ctx.action.params;
const cache = ctx.app.cache as Cache;
if ( if (
associatedName === 'roles' && associatedName === 'roles' &&
resourceName === 'users' && resourceName === 'users' &&
@ -175,9 +163,7 @@ export default class PluginUsersServer extends Plugin {
values?.length values?.length
) { ) {
// Delete cache when the members of a role changed // Delete cache when the members of a role changed
for (const userId of values) { await Promise.all(values.map((userId: number) => this.app.emitAsync('cache:del:roles', { userId })));
await cache.del(`roles:${userId}`);
}
} }
}); });
@ -185,6 +171,15 @@ export default class PluginUsersServer extends Plugin {
if (userDataSyncPlugin && userDataSyncPlugin.enabled) { if (userDataSyncPlugin && userDataSyncPlugin.enabled) {
userDataSyncPlugin.resourceManager.registerResource(new UserDataSyncResource(this.db, this.app.logger)); userDataSyncPlugin.resourceManager.registerResource(new UserDataSyncResource(this.db, this.app.logger));
} }
this.app.db.on('users.beforeUpdate', async (model: Model) => {
if (!model._changed.has('password')) {
return;
}
model.set('passwordChangeTz', Date.now());
await this.app.emitAsync('cache:del:roles', { userId: model.get('id') });
await this.app.emitAsync('cache:del:auth', { userId: model.get('id') });
});
} }
getInstallingData(options: any = {}) { getInstallingData(options: any = {}) {