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);
const res = await agent.resource('auth').check();
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) => {
const token = ctx.getBearerToken();
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;

View File

@ -69,14 +69,14 @@ export class BaseAuth extends Auth {
return null;
}
try {
const { userId, roleName } = await this.jwt.decode(token);
const { userId, roleName, iat, temp } = await this.jwt.decode(token);
if (roleName) {
this.ctx.headers['x-role'] = roleName;
}
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({
filter: {
id: userId,
@ -84,6 +84,10 @@ export class BaseAuth extends Auth {
raw: true,
}),
);
if (temp && user.passwordChangeTz && iat * 1000 < user.passwordChangeTz) {
throw new Error('Token is invalid');
}
return user;
} catch (err) {
this.ctx.logger.error(err, { method: 'check' });
return null;
@ -106,6 +110,7 @@ export class BaseAuth extends Auth {
}
const token = this.jwt.sign({
userId: user.id,
temp: true,
});
return {
user,
@ -119,7 +124,7 @@ export class BaseAuth extends Auth {
return;
}
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));
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')) {
this.auth.setRole(null);
}
if (errs.find((error: { code?: string }) => error.code === 'TOKEN_INVALID')) {
this.auth.setToken(null);
}
throw error;
},
);
@ -119,7 +122,7 @@ export class APIClient extends APIClientSDK {
}
return response;
},
(error) => {
async (error) => {
if (this.silence) {
throw error;
}

View File

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

View File

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

View File

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

View File

@ -123,6 +123,7 @@ export class MockServer extends Application {
jwt.sign(
{
userId: typeof userOrId === 'number' ? userOrId : userOrId?.id,
temp: true,
},
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.resourcer.use(setCurrentRole, { tag: 'setCurrentRole', before: 'acl', after: 'auth' });

View File

@ -102,6 +102,7 @@ describe('actions', () => {
});
afterEach(async () => {
await app.db.clean({ drop: true });
await app.destroy();
});
@ -273,5 +274,28 @@ describe('actions', () => {
expect(res.statusCode).toEqual(400);
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() {
// 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({
name: 'auth',
prefix: 'auth',
@ -85,6 +76,9 @@ export class PluginAuthServer extends Plugin {
const cache = this.app.cache as Cache;
await cache.del(`auth:${user.id}`);
});
this.app.on('cache:del:auth', async ({ userId }) => {
await this.cache.del(`auth:${userId}`);
});
}
async install(options?: InstallOptions) {

View File

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

View File

@ -7,11 +7,9 @@
* 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 { parse } from '@nocobase/utils';
import { resolve } from 'path';
import { Cache } from '@nocobase/cache';
import * as actions from './actions/users';
import { UserModel } from './models/UserModel';
import PluginUserDataSyncServer from '@nocobase/plugin-user-data-sync';
@ -155,19 +153,9 @@ export default class PluginUsersServer extends Plugin {
}
async load() {
await this.importCollections(resolve(__dirname, 'collections'));
this.db.addMigrations({
namespace: 'users',
directory: resolve(__dirname, 'migrations'),
context: {
plugin: this,
},
});
this.app.resourcer.use(async (ctx, next) => {
this.app.resourceManager.use(async (ctx, next) => {
await next();
const { associatedName, resourceName, actionName, values } = ctx.action.params;
const cache = ctx.app.cache as Cache;
if (
associatedName === 'roles' &&
resourceName === 'users' &&
@ -175,9 +163,7 @@ export default class PluginUsersServer extends Plugin {
values?.length
) {
// Delete cache when the members of a role changed
for (const userId of values) {
await cache.del(`roles:${userId}`);
}
await Promise.all(values.map((userId: number) => this.app.emitAsync('cache:del:roles', { userId })));
}
});
@ -185,6 +171,15 @@ export default class PluginUsersServer extends Plugin {
if (userDataSyncPlugin && userDataSyncPlugin.enabled) {
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 = {}) {