Skip to content

Commit

Permalink
NEXT-24677 - Limit admin session time
Browse files Browse the repository at this point in the history
  • Loading branch information
seggewiss committed Jan 3, 2023
1 parent 7619007 commit cd7a89c
Show file tree
Hide file tree
Showing 14 changed files with 134 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
title: Implement logout for inactive users
issue: NEXT-24677
author: Sebastian Seggewiss
author_email: s.seggewiss@shopware.com
author_github: @seggewiss
---
# Administration
* Added 30-minute logout for inactive users
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const { Component } = Shopware;
Component.register('sw-admin', {
template,

inject: ['userActivityService'],

metaInfo() {
return {
title: this.$tc('global.sw-admin-menu.textShopwareAdmin'),
Expand All @@ -19,4 +21,10 @@ Component.register('sw-admin', {
return Shopware.Service('loginService').isLoggedIn();
},
},

methods: {
onUserActivity: Shopware.Utils.debounce(function updateUserActivity() {
this.userActivityService.updateLastUserActivity();
}, 5000),
},
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
<!-- eslint-disable-next-line sw-deprecation-rules/no-twigjs-blocks -->
{% block sw_admin %}
<div id="app">
<div
id="app"
@mousemove="onUserActivity"
@keyup="onUserActivity"
>
<sw-notifications ref="notifications" />
<sw-duplicated-media-v2 v-if="isLoggedIn" />
<router-view />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const { Component } = Shopware;
Component.register('sw-progress-bar', {
template,

inject: ['userActivityService'],

props: {
value: {
type: Number,
Expand Down Expand Up @@ -47,4 +49,10 @@ Component.register('sw-progress-bar', {
};
},
},

watch: {
value() {
this.userActivityService.updateLastUserActivity();
},
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import ShopwareDiscountCampaignService from 'src/app/service/discount-campaign.s
import SearchRankingService from 'src/app/service/search-ranking.service';
import SearchPreferencesService from 'src/app/service/search-preferences.service';
import RecentlySearchService from 'src/app/service/recently-search.service';
import UserActivityService from 'src/app/service/user-activity.service';

/** Import Feature */
import Feature from 'src/core/feature';
Expand Down Expand Up @@ -186,4 +187,7 @@ Application
return new SearchPreferencesService({
userConfigRepository: Shopware.Service('repositoryFactory').create('user_config'),
});
})
.addServiceProvider('userActivityService', () => {
return new UserActivityService();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import UserActivityService from './user-activity.service';

describe('src/app/service/user-activity.service.ts', () => {
let service: UserActivityService | undefined;

beforeEach(() => {
service = new UserActivityService();
});

it('should instantiate', () => {
expect(service instanceof UserActivityService).toBe(true);
});

it('should change last user activity', () => {
Shopware.Context.app.lastActivity = 0;
const date = new Date();
const expectedResult = Math.round(+date / 1000);

service.updateLastUserActivity(date);
expect(Shopware.Context.app.lastActivity).toBe(expectedResult);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* @private
*/
export default class UserActivityService {
updateLastUserActivity(date?: Date): void {
if (date === undefined) {
date = new Date();
}

Shopware.Context.app.lastActivity = Math.round(+date / 1000);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ interface ContextState {
systemCurrencyISOCode: null | string,
systemCurrencyId: null | string,
disableExtensions: boolean,
lastActivity: number,
},
api: {
apiPath: null | string,
Expand Down Expand Up @@ -75,6 +76,7 @@ const ContextStore: Module<ContextState, VuexRootState> = {
systemCurrencyId: null,
systemCurrencyISOCode: null,
disableExtensions: false,
lastActivity: 0,
},
api: {
apiPath: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,11 @@ export default function createContext(context = {}) {
Shopware.State.commit('context/addAppValue', { key, value });
});

// set initial last activity to prevent immediate logout
Shopware.State.commit('context/addAppValue', {
key: 'lastActivity',
value: Math.round(+new Date() / 1000),
});

return Shopware.Context.app;
}
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export default function createRouter(Router, View, moduleFactory, LoginService)
const assetPath = getAssetPath();

router.beforeEach((to, from, next) => {
Shopware.Context.app.lastActivity = Math.round(+new Date() / 1000);

setModuleFavicon(to, assetPath);
const loggedIn = LoginService.isLoggedIn();
const tokenHandler = new Shopware.Helper.RefreshTokenHelper();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe('core/service/login.service.js', () => {
});

beforeEach(() => {
Shopware.Context.app.lastActivity = Math.round(+new Date() / 1000);
window.localStorage.removeItem('redirectFromLogin');
document.cookie = '';
});
Expand Down Expand Up @@ -331,6 +332,7 @@ describe('core/service/login.service.js', () => {

it('should start auto refresh the token after login', async () => {
jest.useFakeTimers();
Shopware.Context.app.lastActivity = Math.round(+new Date() / 1000);

const { loginService, clientMock } = loginServiceFactory();

Expand Down Expand Up @@ -389,4 +391,30 @@ describe('core/service/login.service.js', () => {
const storage = loginService.getStorage();
expect(storage instanceof CookieStorage).toBe(true);
});

it('should logout inactive user', async () => {
// Current time in Seconds - 1501 to be one 1-second over the threshold
Shopware.Context.app.lastActivity = Math.round(+new Date() / 1000) - 1501;

const { loginService, clientMock } = loginServiceFactory();
const logoutListener = jest.fn();
loginService.addOnLogoutListener(logoutListener);

clientMock.onPost('/oauth/token')
.reply(200, {
token_type: 'Bearer',
expires_in: 600,
access_token: 'aCcEsS_tOkEn_first',
refresh_token: 'rEfReSh_ToKeN_first'
});

await loginService.loginByUsername('admin', 'shopware');

expect(clientMock.history.post[0]).toBeDefined();
expect(clientMock.history.post[1]).toBeUndefined();
expect(JSON.parse(clientMock.history.post[0].data).grant_type).toEqual('password');

expect(clientMock.history.post[1]).toBeUndefined();
expect(logoutListener).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -242,13 +242,33 @@ export default function createLoginService(
clearTimeout(autoRefreshTokenTimeoutId);
}

if (lastActivityOverThreshold()) {
logout();
return;
}

const timeUntilExpiry = expiryTimestamp * 1000 - Date.now();

autoRefreshTokenTimeoutId = setTimeout(() => {
void refreshToken();
}, timeUntilExpiry / 2);
}

/**
* Returns true if the last user activity is over the 30-minute threshold
*
* @private
*/
function lastActivityOverThreshold(): boolean {
const lastActivity = Shopware.Context.app.lastActivity;

// (Current time in seconds) - 25 minutes
// 25 minutes + half the 10-minute expiry = 30 minute threshold
const threshold = Math.round(+new Date() / 1000) - 1500;

return lastActivity <= threshold;
}

/**
* Returns saved bearer authentication object. Either you're getting the full object or when you're specifying
* the `section` argument and getting either the token or the expiry date.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type ExtensionSdkService from 'src/core/service/api/extension-sdk.service
import type CartStoreService from 'src/core/service/api/cart-store-api.api.service';
import type CustomSnippetApiService from 'src/core/service/api/custom-snippet.api.service';
import type LocaleFactory from 'src/core/factory/locale.factory';
import type UserActivityService from 'src/app/service/user-activity.service';
import type { ExtensionsState } from './app/state/extensions.store';
import type { ComponentConfig } from './core/factory/component.factory';
import type { TabsState } from './app/state/tabs.store';
Expand Down Expand Up @@ -139,6 +140,7 @@ declare global {
appModulesService: AppModulesService,
cartStoreService: CartStoreService,
customSnippetApiService: CustomSnippetApiService,
userActivityService: UserActivityService,
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface InitContainer extends SubContainer<'init'>{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ describe('src/module/sw-extension/component/sw-ratings/sw-extension-ratings-summ
stubs: {
'sw-extension-rating-stars': true,
'sw-progress-bar': await Shopware.Component.build('sw-progress-bar')
}
},
provide: {
userActivityService: {
updateLastUserActivity: () => {},
},
},
});
}

Expand Down

0 comments on commit cd7a89c

Please sign in to comment.