Automatically Logout Banned Users (#12037)

* wip

* logout banned users, fix and refactor language library and middleware

* req.locals -> res.locals

* fix tests

* redirect to login page
This commit is contained in:
Matteo Pagliazzi
2020-04-02 21:46:01 +02:00
committed by GitHub
parent e9100c7132
commit e92ff9737a
11 changed files with 228 additions and 121 deletions

View File

@@ -0,0 +1,111 @@
import {
getLanguageFromBrowser,
getLanguageFromUser,
} from '../../../../website/server/libs/language';
import {
generateReq,
} from '../../../helpers/api-unit.helper';
describe('language lib', () => {
let req;
beforeEach(() => {
req = generateReq();
});
describe('getLanguageFromUser', () => {
it('uses the user preferred language if avalaible', () => {
const user = {
preferences: {
language: 'it',
},
};
expect(getLanguageFromUser(user, req)).to.equal('it');
});
it('falls back to english if the user preferred language is not avalaible', () => {
const user = {
preferences: {
language: 'bla',
},
};
expect(getLanguageFromUser(user, req)).to.equal('en');
});
});
describe('getLanguageFromBrowser', () => {
it('uses browser specificed language', () => {
req.headers['accept-language'] = 'pt';
expect(getLanguageFromBrowser(req)).to.equal('pt');
});
it('uses first language in series if browser specifies multiple', () => {
req.headers['accept-language'] = 'he, pt, it';
expect(getLanguageFromBrowser(req)).to.equal('he');
});
it('skips invalid lanaguages and uses first language in series if browser specifies multiple', () => {
req.headers['accept-language'] = 'blah, he, pt, it';
expect(getLanguageFromBrowser(req)).to.equal('he');
});
it('uses normal version of language if specialized locale is passed in', () => {
req.headers['accept-language'] = 'fr-CA';
expect(getLanguageFromBrowser(req)).to.equal('fr');
});
it('uses normal version of language if specialized locale is passed in', () => {
req.headers['accept-language'] = 'fr-CA';
expect(getLanguageFromBrowser(req)).to.equal('fr');
});
it('uses es if es is passed in', () => {
req.headers['accept-language'] = 'es';
expect(getLanguageFromBrowser(req)).to.equal('es');
});
it('uses es_419 if applicable es-languages are passed in', () => {
req.headers['accept-language'] = 'es-mx';
expect(getLanguageFromBrowser(req)).to.equal('es_419');
});
it('uses es_419 if multiple es languages are passed in', () => {
req.headers['accept-language'] = 'es-GT, es-MX, es-CR';
expect(getLanguageFromBrowser(req)).to.equal('es_419');
});
it('zh', () => {
req.headers['accept-language'] = 'zh-TW';
expect(getLanguageFromBrowser(req)).to.equal('zh_TW');
});
it('uses english if browser specified language is not compatible', () => {
req.headers['accept-language'] = 'blah';
expect(getLanguageFromBrowser(req)).to.equal('en');
});
it('uses english if browser does not specify', () => {
req.headers['accept-language'] = '';
expect(getLanguageFromBrowser(req)).to.equal('en');
});
it('uses english if browser does not supply an accept-language header', () => {
delete req.headers['accept-language'];
expect(getLanguageFromBrowser(req)).to.equal('en');
});
});
});

View File

@@ -19,7 +19,7 @@ describe('analytics middleware', () => {
next = generateNext(); next = generateNext();
}); });
it('attaches analytics object res.locals', () => { it('attaches analytics object to res', () => {
const attachAnalytics = requireAgain(pathToAnalyticsMiddleware).default; const attachAnalytics = requireAgain(pathToAnalyticsMiddleware).default;
attachAnalytics(req, res, next); attachAnalytics(req, res, next);

View File

@@ -21,28 +21,11 @@ describe('cron middleware', () => {
req; req;
let user; let user;
beforeEach(done => { beforeEach(async () => {
res = generateRes(); res = generateRes();
req = generateReq(); req = generateReq();
user = new User({ user = await res.locals.user.save();
auth: { res.analytics = analyticsService;
local: {
username: 'username',
lowerCaseUsername: 'username',
email: 'email@email.email',
salt: 'salt',
hashed_password: 'hashed_password', // eslint-disable-line camelcase
},
},
});
user.save()
.then(savedUser => {
res.locals.user = savedUser;
res.analytics = analyticsService;
done();
})
.catch(done);
}); });
afterEach(() => { afterEach(() => {

View File

@@ -12,6 +12,9 @@ import { model as User } from '../../../../website/server/models/user';
const { i18n } = common; const { i18n } = common;
// TODO some of the checks here can be simplified to simply check
// that the right parameters are passed to the functions in libs/language
describe('language middleware', () => { describe('language middleware', () => {
describe('res.t', () => { describe('res.t', () => {
let res; let req; let let res; let req; let
@@ -19,6 +22,8 @@ describe('language middleware', () => {
beforeEach(() => { beforeEach(() => {
res = generateRes(); res = generateRes();
// remove the defaul user
res.locals.user = undefined;
req = generateReq(); req = generateReq();
next = generateNext(); next = generateNext();
@@ -57,6 +62,8 @@ describe('language middleware', () => {
beforeEach(() => { beforeEach(() => {
res = generateRes(); res = generateRes();
// remove the defaul user
res.locals.user = undefined;
req = generateReq(); req = generateReq();
next = generateNext(); next = generateNext();
attachTranslateFunction(req, res, next); attachTranslateFunction(req, res, next);
@@ -88,7 +95,7 @@ describe('language middleware', () => {
lang: 'es', lang: 'es',
}; };
req.locals = { res.locals = {
user: { user: {
preferences: { preferences: {
language: 'it', language: 'it',
@@ -108,7 +115,7 @@ describe('language middleware', () => {
context('authorized request', () => { context('authorized request', () => {
it('uses the user preferred language if avalaible', () => { it('uses the user preferred language if avalaible', () => {
req.locals = { res.locals = {
user: { user: {
preferences: { preferences: {
language: 'it', language: 'it',
@@ -122,7 +129,7 @@ describe('language middleware', () => {
}); });
it('falls back to english if the user preferred language is not avalaible', done => { it('falls back to english if the user preferred language is not avalaible', done => {
req.locals = { res.locals = {
user: { user: {
preferences: { preferences: {
language: 'bla', language: 'bla',
@@ -138,7 +145,7 @@ describe('language middleware', () => {
}); });
it('uses the user preferred language even if a session is included in request', () => { it('uses the user preferred language even if a session is included in request', () => {
req.locals = { res.locals = {
user: { user: {
preferences: { preferences: {
language: 'it', language: 'it',

View File

@@ -33,7 +33,7 @@
'resting': showRestingBanner 'resting': showRestingBanner
}" }"
> >
<banned-account-modal /> <!-- <banned-account-modal /> -->
<amazon-payments-modal v-if="!isStaticPage" /> <amazon-payments-modal v-if="!isStaticPage" />
<payments-success-modal /> <payments-success-modal />
<sub-cancel-modal-confirm v-if="isUserLoaded" /> <sub-cancel-modal-confirm v-if="isUserLoaded" />
@@ -266,7 +266,6 @@ import {
} from '@/libs/userlocalManager'; } from '@/libs/userlocalManager';
import svgClose from '@/assets/svg/close.svg'; import svgClose from '@/assets/svg/close.svg';
import bannedAccountModal from '@/components/bannedAccountModal';
const COMMUNITY_MANAGER_EMAIL = process.env.EMAILS_COMMUNITY_MANAGER_EMAIL; // eslint-disable-line const COMMUNITY_MANAGER_EMAIL = process.env.EMAILS_COMMUNITY_MANAGER_EMAIL; // eslint-disable-line
@@ -281,7 +280,6 @@ export default {
BuyModal, BuyModal,
SelectMembersModal, SelectMembersModal,
amazonPaymentsModal, amazonPaymentsModal,
bannedAccountModal,
paymentsSuccessModal, paymentsSuccessModal,
subCancelModalConfirm, subCancelModalConfirm,
subCanceledModal, subCanceledModal,
@@ -385,7 +383,8 @@ export default {
return response; return response;
}, error => { }, error => {
if (error.response.status >= 400) { if (error.response.status >= 400) {
this.checkForBannedUser(error); const isBanned = this.checkForBannedUser(error);
if (isBanned === true) return null; // eslint-disable-line consistent-return
// Don't show errors from getting user details. These users have delete their account, // Don't show errors from getting user details. These users have delete their account,
// but their chat message still exists. // but their chat message still exists.
@@ -403,7 +402,8 @@ export default {
// TODO use a specific error like NotificationNotFound instead of checking for the string // TODO use a specific error like NotificationNotFound instead of checking for the string
const invalidUserMessage = [this.$t('invalidCredentials'), 'Missing authentication headers.']; const invalidUserMessage = [this.$t('invalidCredentials'), 'Missing authentication headers.'];
if (invalidUserMessage.indexOf(errorMessage) !== -1) { if (invalidUserMessage.indexOf(errorMessage) !== -1) {
this.$store.dispatch('auth:logout'); this.$store.dispatch('auth:logout', { redirectToLogin: true });
return null;
} }
// Most server errors should return is click to dismiss errors, with some exceptions // Most server errors should return is click to dismiss errors, with some exceptions
@@ -553,7 +553,7 @@ export default {
// Case where user is not logged in // Case where user is not logged in
if (!parseSettings) { if (!parseSettings) {
return; return false;
} }
const bannedMessage = this.$t('accountSuspended', { const bannedMessage = this.$t('accountSuspended', {
@@ -561,9 +561,10 @@ export default {
userId: parseSettings.auth.apiId, userId: parseSettings.auth.apiId,
}); });
if (errorMessage !== bannedMessage) return; if (errorMessage !== bannedMessage) return false;
this.$root.$emit('bv::show::modal', 'banned-account'); this.$store.dispatch('auth:logout', { redirectToLogin: true });
return true;
}, },
initializeModalStack () { initializeModalStack () {
// Manage modals // Manage modals

View File

@@ -82,7 +82,8 @@ export async function socialAuth (store, params) {
localStorage.setItem(LOCALSTORAGE_AUTH_KEY, userLocalData); localStorage.setItem(LOCALSTORAGE_AUTH_KEY, userLocalData);
} }
export function logout () { export function logout (store, options = {}) {
localStorage.clear(); localStorage.clear();
window.location.href = '/logout-server'; const query = options.redirectToLogin === true ? '?redirectToLogin=true' : '';
window.location.href = `/logout-server${query}`;
} }

View File

@@ -28,7 +28,9 @@ api.logout = {
async handler (req, res) { async handler (req, res) {
if (req.logout) req.logout(); // passportjs method if (req.logout) req.logout(); // passportjs method
req.session = null; req.session = null;
res.redirect('/');
const redirectUrl = req.query.redirectToLogin === 'true' ? '/login' : '/';
res.redirect(redirectUrl);
}, },
}; };

View File

@@ -22,34 +22,10 @@ const momentLangsMapping = {
}; };
export const approvedLanguages = [ export const approvedLanguages = [
'bg', 'bg', 'cs', 'da', 'de', 'en', 'en_GB', 'en@pirate',
'cs', 'es', 'es_419', 'fr', 'he', 'hu', 'id', 'it',
'da', 'ja', 'nl', 'pl', 'pt', 'pt_BR', 'ro', 'ru', 'sk',
'de', 'sr', 'sv', 'tr', 'uk', 'zh', 'zh_TW',
'en',
'en_GB',
'en@pirate',
'es',
'es_419',
'fr',
'he',
'hu',
'id',
'it',
'ja',
'nl',
'pl',
'pt',
'pt_BR',
'ro',
'ru',
'sk',
'sr',
'sv',
'tr',
'uk',
'zh',
'zh_TW',
]; ];
function _loadTranslations (locale) { function _loadTranslations (locale) {

View File

@@ -0,0 +1,52 @@
import accepts from 'accepts';
import _ from 'lodash';
import {
translations,
defaultLangCodes,
multipleVersionsLanguages,
} from './i18n';
function getUniqueListOfLanguages (languages) {
const acceptableLanguages = _(languages).map(lang => lang.slice(0, 2)).uniq().value();
const uniqueListOfLanguages = _.intersection(acceptableLanguages, defaultLangCodes);
return uniqueListOfLanguages;
}
function checkForApplicableLanguageVariant (originalLanguageOptions) {
const languageVariant = _.find(originalLanguageOptions, accepted => {
const trimmedAccepted = accepted.slice(0, 2);
return multipleVersionsLanguages[trimmedAccepted];
});
return languageVariant;
}
export function getLanguageFromBrowser (req) {
const originalLanguageOptions = accepts(req).languages();
const uniqueListOfLanguages = getUniqueListOfLanguages(originalLanguageOptions);
const baseLanguage = (uniqueListOfLanguages[0] || '').toLowerCase();
const languageMapping = multipleVersionsLanguages[baseLanguage];
if (languageMapping) {
let languageVariant = checkForApplicableLanguageVariant(originalLanguageOptions);
if (languageVariant) {
languageVariant = languageVariant.toLowerCase();
} else {
return 'en';
}
return languageMapping[languageVariant] || baseLanguage;
}
return baseLanguage || 'en';
}
export function getLanguageFromUser (user, req) {
const preferredLang = user && user.preferences && user.preferences.language;
const lang = translations[preferredLang] ? preferredLang : getLanguageFromBrowser(req);
return lang;
}

View File

@@ -7,6 +7,8 @@ import {
model as User, model as User,
} from '../models/user'; } from '../models/user';
import gcpStackdriverTracer from '../libs/gcpTraceAgent'; import gcpStackdriverTracer from '../libs/gcpTraceAgent';
import common from '../../common';
import { getLanguageFromUser } from '../libs/language';
const COMMUNITY_MANAGER_EMAIL = nconf.get('EMAILS_COMMUNITY_MANAGER_EMAIL'); const COMMUNITY_MANAGER_EMAIL = nconf.get('EMAILS_COMMUNITY_MANAGER_EMAIL');
const USER_FIELDS_ALWAYS_LOADED = ['_id', 'notifications', 'preferences', 'auth', 'flags']; const USER_FIELDS_ALWAYS_LOADED = ['_id', 'notifications', 'preferences', 'auth', 'flags'];
@@ -72,7 +74,17 @@ export function authWithHeaders (options = {}) {
.exec() .exec()
.then(user => { .then(user => {
if (!user) throw new NotAuthorized(res.t('invalidCredentials')); if (!user) throw new NotAuthorized(res.t('invalidCredentials'));
if (user.auth.blocked) throw new NotAuthorized(res.t('accountSuspended', { communityManagerEmail: COMMUNITY_MANAGER_EMAIL, userId: user._id }));
if (user.auth.blocked) {
// We want the accountSuspended message to be translated but the language
// middleware hasn't run yet so we pick it manually
const language = getLanguageFromUser(user, req);
throw new NotAuthorized(common.i18n.t('accountSuspended', {
communityManagerEmail: COMMUNITY_MANAGER_EMAIL,
userId: user._id,
}, language));
}
res.locals.user = user; res.locals.user = user;
req.session.userId = user._id; req.session.userId = user._id;

View File

@@ -1,60 +1,15 @@
import accepts from 'accepts';
import _ from 'lodash';
import { model as User } from '../models/user'; import { model as User } from '../models/user';
import common from '../../common'; import common from '../../common';
import { import {
translations, translations,
defaultLangCodes,
multipleVersionsLanguages,
} from '../libs/i18n'; } from '../libs/i18n';
import {
getLanguageFromUser,
getLanguageFromBrowser,
} from '../libs/language';
const { i18n } = common; const { i18n } = common;
function _getUniqueListOfLanguages (languages) {
const acceptableLanguages = _(languages).map(lang => lang.slice(0, 2)).uniq().value();
const uniqueListOfLanguages = _.intersection(acceptableLanguages, defaultLangCodes);
return uniqueListOfLanguages;
}
function _checkForApplicableLanguageVariant (originalLanguageOptions) {
const languageVariant = _.find(originalLanguageOptions, accepted => {
const trimmedAccepted = accepted.slice(0, 2);
return multipleVersionsLanguages[trimmedAccepted];
});
return languageVariant;
}
function _getFromBrowser (req) {
const originalLanguageOptions = accepts(req).languages();
const uniqueListOfLanguages = _getUniqueListOfLanguages(originalLanguageOptions);
const baseLanguage = (uniqueListOfLanguages[0] || '').toLowerCase();
const languageMapping = multipleVersionsLanguages[baseLanguage];
if (languageMapping) {
let languageVariant = _checkForApplicableLanguageVariant(originalLanguageOptions);
if (languageVariant) {
languageVariant = languageVariant.toLowerCase();
} else {
return 'en';
}
return languageMapping[languageVariant] || baseLanguage;
}
return baseLanguage || 'en';
}
function _getFromUser (user, req) {
const preferredLang = user && user.preferences && user.preferences.language;
const lang = translations[preferredLang] ? preferredLang : _getFromBrowser(req);
return lang;
}
export function attachTranslateFunction (req, res, next) { export function attachTranslateFunction (req, res, next) {
res.t = function reqTranslation (...args) { res.t = function reqTranslation (...args) {
return i18n.t(...args, req.language); return i18n.t(...args, req.language);
@@ -64,26 +19,33 @@ export function attachTranslateFunction (req, res, next) {
} }
export function getUserLanguage (req, res, next) { export function getUserLanguage (req, res, next) {
if (req.query.lang) { // In case the language is specified in the request url, use it // In case the language is specified in the request url, use intersection
if (req.query.lang) {
req.language = translations[req.query.lang] ? req.query.lang : 'en'; req.language = translations[req.query.lang] ? req.query.lang : 'en';
return next(); return next();
}
// If the request is authenticated, use the user's preferred language // If the request is authenticated, use the user's preferred language
} if (req.locals && req.locals.user) { if (res.locals && res.locals.user) {
req.language = _getFromUser(req.locals.user, req); req.language = getLanguageFromUser(res.locals.user, req);
return next(); return next();
} if (req.session && req.session.userId) { // Same thing if the user has a valid session }
// Same thing if the user has a valid session
if (req.session && req.session.userId) {
return User.findOne({ return User.findOne({
_id: req.session.userId, _id: req.session.userId,
}, 'preferences.language') }, 'preferences.language')
.lean() .lean()
.exec() .exec()
.then(user => { .then(user => {
req.language = _getFromUser(user, req); req.language = getLanguageFromUser(user, req);
return next(); return next();
}) })
.catch(next); .catch(next);
} // Otherwise get from browser }
req.language = _getFromUser(null, req);
// Otherwise get from browser
req.language = getLanguageFromBrowser(req);
return next(); return next();
} }