diff --git a/admin/src/js/controllers/edit-user.js b/admin/src/js/controllers/edit-user.js index 86661d1ced4..deaabe1c8ca 100644 --- a/admin/src/js/controllers/edit-user.js +++ b/admin/src/js/controllers/edit-user.js @@ -57,6 +57,12 @@ angular }); }; + const validateSkipPasswordPermission = () => { + $scope.skipPasswordChange = chtDatasource.v1.hasPermissions( + ['can_skip_password_change'], $scope.editUserModel.roles, $scope.permissions + ); + }; + const formatDate = (settings, date) => { const format = settings.reported_date_format || 'DD-MMM-YYYY HH:mm:ss'; return moment(date).format(format); @@ -151,6 +157,7 @@ angular this.setupPromise = determineEditUserModel() .then(model => { $scope.editUserModel = model; + validateSkipPasswordPermission(); }) .catch(err => { $log.error('Error determining user model', err); @@ -298,7 +305,6 @@ angular return userHasPermission; }; - const isOnlineUser = (roles) => { if (!$scope.roles) { return true; diff --git a/admin/src/templates/edit_user.html b/admin/src/templates/edit_user.html index 5c99934f9d6..884a4e1a538 100644 --- a/admin/src/templates/edit_user.html +++ b/admin/src/templates/edit_user.html @@ -108,6 +108,7 @@ class="form-group" ng-class="{ 'has-error': errors.password, 'required': !editUserModel.id, 'hidden': allowTokenLogin && (editUserModel.token_login || (editUserModel.token_login !== false && editUserModel.tokenLoginEnabled)) }"> +
update.password.help
{ }, permissions: { can_have_multiple_places: ['community-health-assistant'], + can_skip_password_change: ['community-health-assistant'], }, }); http = { get: sinon.stub() }; @@ -864,4 +865,37 @@ describe('EditUserCtrl controller', () => { }); }); }); + + describe('skipPasswordChange', () => { + let user; + + beforeEach(() => { + user = { + _id: 'user.id', + name: 'user.name', + fullname: 'user.fullname', + email: 'user@email.com', + phone: 'user.phone', + facility_id: 'abc', + contact_id: 'xyz', + language: 'zz', + }; + }); + + it('should set skipPasswordChange to false if user does not have can_skip_password_change permission', () => { + user.roles = ['supervisor']; + + return mockEditAUser(user).setupPromise.then(() => { + chai.expect(scope.skipPasswordChange).to.equal(false); + }); + }); + + it('should set skipPasswordChange to true if user has can_skip_password_change permission', () => { + user.roles = ['community-health-assistant']; + + return mockEditAUser(user).setupPromise.then(() => { + chai.expect(scope.skipPasswordChange).to.equal(true); + }); + }); + }); }); diff --git a/api/resources/translations/messages-ar.properties b/api/resources/translations/messages-ar.properties index 1b86b058362..561bd1b5ce2 100644 --- a/api/resources/translations/messages-ar.properties +++ b/api/resources/translations/messages-ar.properties @@ -400,6 +400,11 @@ bulkdelete.confirm.title = هل تريد حذف السجل؟ bulkdelete.confirm.title.plural = هل تريد حذف السجلات المحدّدة؟ call = اتصال case_id = معرّف الحالة +change.password.confirm.password = تأكيد كلمة المرور +change.password.hint = والأرقام والأحرف الخاصة.استخدم الأحرف الكبيرة +change.password.new.password = كلمة المرور الجديدة +change.password.submit = تغيير كلمة المرور +change.password.title = تغيير كلمة المرور child_birth_date = تاريخ ميلاد الطفل child_birth_outcome = حصيلة ميلاد الطفل child_birth_weight = وزن الطفل عند الولادة @@ -965,8 +970,11 @@ partner.logo.upload = تحميل شعار الشريك partner.name.field = اسم الشريك partner.supporting = الشركاء الداعمون partner.tab.partners = الشركاء +password.current.incorrect = كلمة المرور الحالية غير صحيحة password.incorrect = كلمة المرور غير صحيحة. password.length.minimum = يجب أن تكون كلمة المرور مؤلفة على الأقل من {{minimum}} حرفاً. +password.must.match =وتأكيد كلمة المرور يجب أن تتطابق كلمة المرور +password.same = الحالية تكون مختلفة عن كلمة المرور كلمة المرور الجديدة يجب أن password.update = تحديث كلمة المرور password.weak = كلمة المرور سهلة جداً ليتم تخمينها. يرجى تضمين مجموعة متنوعة من الحروف لجعلها أكثر تعقيداً. patient\ id\ not\ found\ response = يُرجى إرسال الرسالة التالية إذا اجتازت جميع عمليات التحقق ولكن لم يتم العثور على معرّف المريضـ(ـة). diff --git a/api/resources/translations/messages-en.properties b/api/resources/translations/messages-en.properties index 3bc93546cdc..2ad8732a487 100644 --- a/api/resources/translations/messages-en.properties +++ b/api/resources/translations/messages-en.properties @@ -400,6 +400,11 @@ bulkdelete.confirm.title = Delete record? bulkdelete.confirm.title.plural = Delete selected records? call = Call case_id = Case ID +change.password.confirm.password = Confirm password +change.password.hint = Use uppercase letters, numbers, and special characters. +change.password.new.password = New password +change.password.submit = Change password +change.password.title = Change your password child_birth_date = Child birth date child_birth_outcome = Child birth outcome child_birth_weight = Child birth weight @@ -654,8 +659,8 @@ enketo.geopicker.altitude = altitude (m) enketo.geopicker.closepolygon = close polygon enketo.geopicker.kmlcoords = KML coordinates enketo.geopicker.kmlpaste = paste KML coordinates here -enketo.geopicker.latitude = latitude (x.y ) -enketo.geopicker.longitude = longitude (x.y ) +enketo.geopicker.latitude = latitude (x.y °) +enketo.geopicker.longitude = longitude (x.y °) enketo.geopicker.points = points enketo.geopicker.searchPlaceholder = search for place or address enketo.geopicker.removePoint = This will completely remove the current geopoint from the list of geopoints and cannot be undone. Are you sure you want to do this? @@ -964,8 +969,11 @@ partner.logo.upload = Upload partner logo partner.name.field = Partner name partner.supporting = Supporting partners partner.tab.partners = Partners +password.current.incorrect = Current password is not correct password.incorrect = Password is not correct. password.length.minimum = The password must be at least {{minimum}} characters long. +password.must.match = Password and confirm password must match +password.same = New password must be different from current password password.update = Update password password.weak = The password is too easy to guess. Include a range of characters to make it more complex. patient\ id\ not\ found\ response = Send the following response message if the validations pass but the Medic ID is not located. @@ -1272,6 +1280,7 @@ translation.add = Add new translation key translation.key = Translation key unique.id = Unique ID unknown.contact = Unknown contact +update.password.help = User will be required to reset their password on their next login upgrade = Upgrade upgrade.description = To upgrade your application to a specific release, beta, or branch, it is recommended that you stage first. This allows for background work to be done to prepare the installation without interrupting users. Once staging is complete, click Install to proceed with the upgrade. This action cannot be undone, so please make sure your data has been backed up and your users are notified of downtime. upload = Upload diff --git a/api/resources/translations/messages-es.properties b/api/resources/translations/messages-es.properties index 4dcc0aa8506..418cfdef6bd 100644 --- a/api/resources/translations/messages-es.properties +++ b/api/resources/translations/messages-es.properties @@ -400,6 +400,11 @@ bulkdelete.confirm.title = ¿Eliminar el registro? bulkdelete.confirm.title.plural = ¿Eliminar registros seleccionados? call = Llamar case_id = Identificación del caso +change.password.confirm.password = Confirmar contraseña +change.password.hint = Utilice letras mayúsculas, números y caracteres especiales. +change.password.new.password = Nueva contraseña +change.password.submit = Cambiar la contraseña +change.password.title = Cambiar contraseña child_birth_date = Fecha de nacimiento del niño child_birth_outcome = Resultado del nacimiento del niño child_birth_weight = Peso del niño al nacer @@ -964,8 +969,11 @@ partner.logo.upload = Subir logo del socio partner.name.field = Nombre del socio partner.supporting = Socios que está apoyando partner.tab.partners = Socios +password.current.incorrect = La contraseña actual no es correcta password.incorrect = La contraseña no es correcta. password.length.minimum = La contraseña debe tener al menos {{minimum}} caracteres. +password.must.match = Las contraseñas y la contraseña de confirmación deben coincidir +password.same = La nueva contraseña debe ser diferente de la contraseña actual password.update = Actualizar contraseña password.weak = La contraseña es demasiado fácil de adivinar. Incluya más variedad de caracteres para hacerlo más complejo. patient\ id\ not\ found\ response = Enviar el siguiente mensaje de respuesta, sí las validaciones pasan correctamente pero no se encontró el Medic ID. @@ -1272,6 +1280,7 @@ translation.add = Agregar Traducción translation.key = Clave de traducción unique.id = Identificación única unknown.contact = Contacto desconocido +update.password.help = El usuario deberá restablecer su contraseña en su próximo inicio de sesión upgrade = Actualizar upgrade.description = Para actualizar su aplicación a una versión, beta o rama específica, se recomienda realizar primero un respaldo. Esto permite realizar un trabajo en segundo plano para preparar la instalación sin interrumpir a los usuarios. Una vez que se complete el respaldo, haga click en Instalar para continuar con la actualización. Esta acción no se puede deshacer, así que asegúrese de que se haya respaldado sus datos y de que se hayan notificado a sus usuarios sobre un posible tiempo de inactividad en el sistema. upload = Subir diff --git a/api/resources/translations/messages-fr.properties b/api/resources/translations/messages-fr.properties index 0b9923ec74a..7fe38a5b708 100644 --- a/api/resources/translations/messages-fr.properties +++ b/api/resources/translations/messages-fr.properties @@ -400,6 +400,11 @@ bulkdelete.confirm.title = Supprimer l'enregistrement? bulkdelete.confirm.title.plural = Supprimer les enregistrements sélectionnés? call = Appeler case_id = ID du cas +change.password.confirm.password = Confirmer le mot de passe +change.password.hint = Utilisez une combinaison de lettres majuscules, de chiffres et de caractères spéciaux. +change.password.new.password = Nouveau mot de passe +change.password.submit = Changer le mot de passe +change.password.title = Changez votre mot de passe child_birth_date = Date de naissance de l'enfant child_birth_outcome = Résultat de la naissance de l'enfant child_birth_weight = Poids de l'enfant à la naissance @@ -964,8 +969,11 @@ partner.logo.upload = Télécharger le logo du partenaire partner.name.field = Nom du partenaire partner.supporting = Partenaires de soutien partner.tab.partners = Partenaires +password.current.incorrect = Le mot de passe actuel n'est pas correct password.incorrect = Mot de passe incorrect password.length.minimum = Le mot de passe doit être au moins {{minimum}} caractères. +password.must.match = Le mot de passe et la confirmation du mot de passe doivent correspondre +password.same = Le nouveau mot de passe doit être différent du mot de passe actuel password.update = Mettre à jour mot de passe password.weak = Le mot de passe est trop facile à deviner. Inclure au moins une lettre majuscule, un chiffre et un caractère spécial. patient\ id\ not\ found\ response = Envoyer cette réponse si les validations passent, mais l'ID du patient n'est pas retrouvé. @@ -1272,6 +1280,7 @@ translation.add = Ajouter une traduction translation.key = Clé de traduction unique.id = ID unique unknown.contact = Contact inconnu +update.password.help = L'utilisateur devra réinitialiser son mot de passe lors de sa prochaine connexion upgrade = Mise à jour upgrade.description = Pour mettre à jour votre application vers une version, une version bêta ou une branche spécifique, il est recommandé d'effectuer d'abord une étape. Cela permet d'effectuer un travail en arrière-plan pour préparer l'installation sans interrompre les utilisateurs. Une fois la préparation terminée, cliquez sur Installer pour procéder à la mise à jour. Cette action ne peut pas être annulée, veuillez donc vous assurer que vos données ont été sauvegardées et que vos utilisateurs sont informés des temps d'arrêt. upload = Télécharger diff --git a/api/resources/translations/messages-id.properties b/api/resources/translations/messages-id.properties index 2e5074230a7..e90460ffc59 100644 --- a/api/resources/translations/messages-id.properties +++ b/api/resources/translations/messages-id.properties @@ -390,6 +390,11 @@ bulkdelete.confirm.title = Hapus pencatatan? bulkdelete.confirm.title.plural = Hapus pencatatan yang dipilih? call = Telepon case_id = ID Kasus +change.password.confirm.password = Konfirmasikan kata sandi +change.password.hint = Gunakan huruf besar, angka, dan karakter khusus. +change.password.new.password = Kata sandi baru +change.password.submit = Ubah kata sandi +change.password.title = Ubah kata sandi Anda child_birth_date = Tanggal Lahir Anak child_birth_outcome = Outcome Anak dilahirkan child_birth_weight = Berat Lahir Anak @@ -884,8 +889,11 @@ partner.logo.upload = partner.name.field = partner.supporting = partner.tab.partners = +password.current.incorrect = Kata sandi saat ini salah password.incorrect = Kata sandi tidak benar. password.length.minimum = Kata sandi harus setidaknya {{minimum}} karakter. +password.must.match = Kata sandi dan konfirmasi kata sandi harus cocok +password.same = Kata sandi baru harus berbeda dengan kata sandi saat ini password.update = Perbaharui Kata Sandi password.weak = Kata sandinya terlalu mudah. Sertakan setidaknya 1 huruf besar, 1 angka, dan 1 karakter khusus. patient\ id\ not\ found\ response = Kirim pesan respon ini bila lolos validasi tetapi Medic ID tidak ditemukan diff --git a/api/resources/translations/messages-ne.properties b/api/resources/translations/messages-ne.properties index 78fa02deecf..19def970b1d 100644 --- a/api/resources/translations/messages-ne.properties +++ b/api/resources/translations/messages-ne.properties @@ -400,6 +400,11 @@ bulkdelete.confirm.title = रेकर्ड मेटाउने हो? bulkdelete.confirm.title.plural = चयन गरिएका रेकर्डहरू मेट्ने हो? call = कल case_id = केस आईडी +change.password.confirm.password = पासवर्ड पुष्टि गर्नुहोस् +change.password.hint = ठूला अक्षर, अङ्क र चिन्हहरूको मिश्रण भएको एउटा भरपर्दो पासवर्ड सिर्जना गर्नुहोस् +change.password.new.password = नयाँ पासवर्ड +change.password.submit = पासवर्ड परिवर्तन गर्नुहोस् +change.password.title = आफ्नो पासवर्ड परिवर्तन गर्नुहोस् child_birth_date = बच्चाको जन्म मिति child_birth_outcome = बच्चाको जन्मावस्था child_birth_weight = बच्चाको जन्म तौल @@ -964,8 +969,11 @@ partner.logo.upload = पार्टनर लोगो अपलोड गर partner.name.field = पार्टनरको नाम partner.supporting = सहयोगी पार्टनरहरू partner.tab.partners = पार्टनरहरू +password.current.incorrect = वर्तमान पासवर्ड गलत छ password.incorrect = पासवर्ड मिलेन। password.length.minimum = पासवर्ड कम्तीमा {{minimum}} अक्षरको हुनुपर्छ। +password.must.match = तपाईंले पासवर्ड हाल्नुहोस् र पासवर्ड पुष्टि गर्नुहोस् नामक फिल्डमा हाल्नुभएको पासवर्ड एउटै छैन। फेरि प्रयास गर्नुहोस्। +password.same = नयाँ पासवर्ड वर्तमान पासवर्ड भन्दा फरक हुनुपर्छ password.update = अपडेट पासवर्ड password.weak = यो पासवर्ड कमजोर छ। patient\ id\ not\ found\ response = बिरामिको आईडी नपाइएमा पठाइने सन्देश @@ -1272,6 +1280,7 @@ translation.add = नयाँ अनुवाद कुञ्जी थप् translation.key = अनुवाद कुञ्जी unique.id = आईडी unknown.contact = अपरिचित सम्पर्क +update.password.help = यो प्रयोगकर्ताले अर्को पटक लगइन गर्दा आफ्नो पासवर्ड पुन: सेट गर्नुपर्नेछ। upgrade = अपग्रेड upgrade.description = तपाईँको एपलाई कुनै रिलीज, बीटा, वा ब्रान्चमा अपग्रेड गर्न, पहिलो चरणको रूपमा स्टेज गर्न सिफारिस गरिन्छ। यसले प्रयोगकर्ताहरूलाई अवरोध नगरी पृष्ठभूमिमा इन्स्टलेसन तयारी गर्छ। स्टेजिंग पुरा भएपछि, अपग्रेडको लागि 'इन्स्टल गर्नुहोस्' क्लिक गर्नुहोस्। यो पछि, यसलाई पहिलाकै अवस्थामा लैजान सकिँदैन, त्यसैले कृपया तपाईँको डेटा ब्याकअप गरिएको छ र तपाईँका प्रयोगकर्ताहरूलाई सेवा अवरुद्ध रहने बारे जानकारी दिइएको छ भन्ने निश्चित गर्नुहोस्। upload = अपलोड diff --git a/api/resources/translations/messages-sw.properties b/api/resources/translations/messages-sw.properties index 13c4eadb09e..590b6bd2115 100644 --- a/api/resources/translations/messages-sw.properties +++ b/api/resources/translations/messages-sw.properties @@ -402,6 +402,11 @@ bulkdelete.confirm.title = Futa rekodi? bulkdelete.confirm.title.plural = Ungependa kufuta rekodi ulizochagua? call = Piga simu case_id = Kitambulisho cha kesi +change.password.confirm.password = Thibitisha nenosiri +change.password.hint = Tumia herufi kubwa, nambari na herufi maalum. +change.password.new.password = Nenosiri mpya +change.password.submit = Badilisha nenosiri +change.password.title = Badilisha nenosiri lako child_birth_date = Tarehe ya kuzaliwa mtoto child_birth_outcome = Matokeo ya mtoto mzaliwa child_birth_weight = Uzani wa mtoto mzaliwa @@ -964,8 +969,11 @@ partner.logo.upload = Pakia nembo ya mshirika partner.name.field = Jina la mshirika partner.supporting = Washirika wanaounga mkono partner.tab.partners = Washirika +password.current.incorrect = Nenosiri la sasa si sahihi password.incorrect = Nenosiri si sahihi password.length.minimum = Nenosiri inapaswa kuwa na wahusika {{minimum}} kwenda juu +password.must.match = Nenosiri na uthibitisho wa nenosiri lazima zilingane +password.same = Nenosiri mpya lazima liwe tofauti na nenosiri la sasa password.update = Badilisha nenosiri password.weak = Nywila ni rahisi sana nadhani. Jumuisha anuwai ya herufi ili kuifanya iwe ngumu zaidi. patient\ id\ not\ found\ response = Tuma ujumbe wa majibu ufuatao kama validations zimepitishwa lakini ID ya mgonjwa haiko @@ -1271,7 +1279,8 @@ training_materials.page.title = Vifaa vya mafunzo translation.add = Ongeza tafsiri translation.key = Ufunguo wa tafsiri unique.id = Kitambulisho cha kipekee -unknown.contact = Mtu asiyejulikana +unknown.contact = Mtu asiyejulikana +update.password.help = Mtumiaji atahitajika kuweka upya nenosiri lake wakati wa kuingia ujayo upgrade = Boresha upgrade.description = Ili kupata toleo jipya la programu yako, inashauriwa uweke jukwaani kwanza. Hii inaruhusu kazi ya chinichini kufanywa ili kuandaa usakinishaji bila kukatiza watumiaji. Mara tu uwekaji jukwaa utakapokamilika, bofya Sakinisha ili kuendelea na uboreshaji. Kitendo hiki hakiwezi kutenduliwa, kwa hivyo tafadhali hakikisha kwamba data yako imechelezwa na watumiaji wako wanaarifiwa kuhusu muda wa hitilafu. upload = Pakia diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index e43340e69af..61c3350bd92 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -8,7 +8,7 @@ const privacyPolicy = require('../services/privacy-policy'); const logger = require('@medic/logger'); const db = require('../db'); const dataContext = require('../services/data-context'); -const { tokenLogin, roles, users } = require('@medic/user-management')(config, db, dataContext); +const { tokenLogin, roles, users, validatePassword } = require('@medic/user-management')(config, db, dataContext); const localeUtils = require('locale'); const cookie = require('../services/cookie'); const brandingService = require('../services/branding'); @@ -17,6 +17,18 @@ const template = require('../services/template'); const rateLimitService = require('../services/rate-limit'); const serverUtils = require('../server-utils'); +const PASSWORD_RESET_URL = '/medic/password-reset'; + +const ERROR_KEY_MAPPING = { + // Ignore Sonar false positive that these are hard-coded credentials. + // These are css error classes for password reset html + 'password.weak': 'password-weak', //NoSONAR + 'password.length.minimum': 'password-short', //NoSONAR + 'password.current.incorrect': 'current-password-incorrect', //NoSONAR + 'password.same': 'password-same', //NoSONAR + 'fields.required': 'fields-required', //NoSONAR +}; + const templates = { login: { file: path.join(__dirname, '..', 'templates', 'login', 'index.html'), @@ -50,6 +62,29 @@ const templates = { 'privacy.policy' ], }, + passwordReset: { + file: path.join(__dirname, '..', 'templates', 'login', 'password-reset.html'), + translationStrings: [ + 'login.show_password', + 'login.hide_password', + 'change.password.title', + 'change.password.hint', + 'change.password.submit', + 'change.password.new.password', + 'change.password.confirm.password', + 'password.weak', + 'password.length.minimum', + 'password.must.match', + 'user.password.current', + 'password.current.incorrect', + 'password.same', + 'fields.required' + ], + } +}; + +const skipPasswordChange = (user) => { + return !user?.password_change_required; }; const getHomeUrl = userCtx => { @@ -186,36 +221,46 @@ const setUserCtxCookie = (res, userCtx) => { cookie.setUserCtx(res, JSON.stringify(content)); }; -const setCookies = (req, res, sessionRes) => { +const setCookies = async (req, res, sessionRes) => { const sessionCookie = getSessionCookie(sessionRes); if (!sessionCookie) { throw { status: 401, error: 'Not logged in' }; } const options = { headers: { Cookie: sessionCookie } }; - return getUserCtxRetry(options) - .then(userCtx => { - cookie.setSession(res, sessionCookie); - setUserCtxCookie(res, userCtx); - // Delete login=force cookie - res.clearCookie('login'); - - return Promise.resolve() - .then(() => { - if (roles.isDbAdmin(userCtx)) { - return users.createAdmin(userCtx); - } - }) - .then(() => { - const selectedLocale = req.body.locale - || config.get('locale'); - cookie.setLocale(res, selectedLocale); - return getRedirectUrl(userCtx, req.body.redirect); - }); - }) - .catch(err => { - logger.error(`Error getting authCtx %o`, err); - throw { status: 401, error: 'Error getting authCtx' }; - }); + try { + const userCtx = await getUserCtxRetry(options); + if (roles.isDbAdmin(userCtx)) { + await users.createAdmin(userCtx); + } + + const user = await users.getUserDoc(userCtx.name); + if (!skipPasswordChange(user)) { + return redirectToPasswordReset(req, res, userCtx); + } + return redirectToApp({ req, res, sessionCookie, userCtx }); + } catch (err) { + logger.error(`Error getting authCtx %o`, err); + throw { status: 401, error: 'Error getting authCtx' }; + } +}; + +const redirectToApp = async ({ req, res, sessionCookie, userCtx }) => { + cookie.setSession(res, sessionCookie); + setUserCtxCookie(res, userCtx); + cookie.clearCookie(res, 'login'); + setUserLocale(req, res); + return getRedirectUrl(userCtx, req.body.redirect); +}; + +const redirectToPasswordReset = (req, res, userCtx) => { + setUserCtxCookie(res, userCtx); + setUserLocale(req, res); + return PASSWORD_RESET_URL; +}; + +const setUserLocale = (req, res) => { + const selectedLocale = req.body.locale || config.get('locale'); + cookie.setLocale(res, selectedLocale); }; const renderTokenLogin = (req, res) => { @@ -296,26 +341,112 @@ const renderLogin = (req) => { return render('login', req); }; +const renderPasswordReset = (req) => { + return render('passwordReset', req); +}; + +const validatePasswordReset = (password) => { + const error = validatePassword(password); + + if (!error) { + return { isValid: true }; + } + + return { + isValid: false, + error: ERROR_KEY_MAPPING[error.message.translationKey], + params: error.message.translationParams + }; +}; + +const validateSession = async (req) => { + const sessionRes = await createSession(req); + if (sessionRes.status !== 200) { + const error = new Error('Not logged in'); + error.status = sessionRes.status; + error.error = 'Not logged in'; + throw error; + } + return sessionRes; +}; + +const sendLoginErrorResponse = (e, res) => { + if (e.status === 401) { + return res.status(401).json({ error: e.error }); + } + logger.error('Error logging in: %o', e); + return res.status(500).json({ error: 'Unexpected error logging in' }); +}; + const login = async (req, res) => { try { - const sessionRes = await createSession(req); - if (sessionRes.status !== 200) { - res.status(sessionRes.status).json({ error: 'Not logged in' }); - } else { - const redirectUrl = await setCookies(req, res, sessionRes); - res.status(302).send(redirectUrl); - } + const sessionRes = await validateSession(req); + const redirectUrl = await setCookies(req, res, sessionRes); + res.status(302).send(redirectUrl); } catch (e) { - if (e.status === 401) { - return res.status(401).json({ error: e.error }); + return sendLoginErrorResponse(e, res); + } +}; + +const updatePassword = (user, newPassword, req) => { + const updateData = { + password: newPassword, + password_change_required: false, + }; + const appUrl = `${req.protocol}://${req.hostname}`; + return users.updateUser(user.name, updateData, true, appUrl); +}; + +const validateCurrentPassword = async (username, currentPassword, newPassword) => { + try { + await request.get({ + url: new URL('/_session', environment.serverUrlNoAuth).toString(), + json: true, + auth: { username: username, password: currentPassword }, + }); + + if (currentPassword === newPassword) { + return { + isValid: false, + error: ERROR_KEY_MAPPING['password.same'], + }; + } + return { isValid: true }; + } catch (err) { + if (err.status === 401) { + return { + isValid: false, + error: ERROR_KEY_MAPPING['password.current.incorrect'], + }; } - logger.error('Error logging in: %o', e); - res.status(500).json({ error: 'Unexpected error logging in' }); + throw err; + } +}; + +const passwordResetValidation = async (username, currentPassword, password) => { + const validation = validatePasswordReset(password); + if (!validation.isValid) { + return { + status: 400, + ...validation, + }; + } + + const currentPasswordValidation = await validateCurrentPassword(username, currentPassword, password); + if (!currentPasswordValidation.isValid) { + return { + status: 400, + ...currentPasswordValidation, + }; } + + return { isValid: true }; }; + module.exports = { renderLogin, + renderPasswordReset, get: (req, res, next) => { return renderLogin(req) @@ -324,6 +455,7 @@ module.exports = { 'Link', '; rel=preload; as=style, ' + '; rel=preload; as=script, ' + + '; rel=preload; as=script, ' + '; rel=preload; as=script' ); res.send(body); @@ -351,6 +483,48 @@ module.exports = { }); }, + getPasswordReset: (req, res, next) => { + return renderPasswordReset(req) + .then(body => { + res.setHeader( + 'Link', + '; rel=preload; as=style, ' + + '; rel=preload; as=script, ' + + '; rel=preload; as=script' + ); + res.send(body); + }) + .catch(next); + }, + resetPassword: async (req, res) => { + const limited = await rateLimitService.isLimited(req); + if (limited) { + return serverUtils.rateLimited(req, res); + } + + try { + const { username, currentPassword, password, locale } = req.body; + const validationResult = await passwordResetValidation(username, currentPassword, password); + if (!validationResult.isValid) { + return res.status(validationResult.status).json({ + error: validationResult.error, + params: validationResult.params + }); + } + + const userDoc = await users.getUserDoc(username); + await updatePassword(userDoc, password, req); + + req.body = { user: username, password, locale }; + const sessionRes = await createSessionRetry(req); + const redirectUrl = await setCookies(req, res, sessionRes); + return res.status(302).send(redirectUrl); + } catch (err) { + logger.error('Error updating password: %o', err); + const status = err.status || 500; + res.status(status).json({ error: err.error || 'Error updating password' }); + } + }, tokenGet: (req, res, next) => renderTokenLogin(req, res).catch(next), tokenPost: async (req, res, next) => { const limited = await rateLimitService.isLimited(req); diff --git a/api/src/generate-service-worker.js b/api/src/generate-service-worker.js index a827e389755..9042cff62d5 100644 --- a/api/src/generate-service-worker.js +++ b/api/src/generate-service-worker.js @@ -53,6 +53,10 @@ const getLoginPageContents = async () => { return await loginController.renderLogin(); }; +const getPasswordResetPageContents = async () => { + return await loginController.renderPasswordReset(); +}; + const appendExtensionLibs = async (config) => { const libs = await extensionLibs.getAll(); // cache this even if there are no libs so offline client knows there are no libs @@ -99,6 +103,7 @@ const writeServiceWorkerFile = async () => { templatedURLs: { '/': ['webapp/index.html'], // Webapp's entry point '/medic/login': await getLoginPageContents(), + '/medic/password-reset': await getPasswordResetPageContents(), '/medic/_design/medic/_rewrite/': ['webapp/appcache-upgrade.html'] }, ignoreURLParametersMatching: [/redirect/, /username/], diff --git a/api/src/public/login/auth-utils.js b/api/src/public/login/auth-utils.js new file mode 100644 index 00000000000..2c0ba654b88 --- /dev/null +++ b/api/src/public/login/auth-utils.js @@ -0,0 +1,128 @@ +window.AuthUtils = (function() { + + const setState = (className) => { + const form = document.getElementById('form'); + if (!form) { + return; + } + form.className = className; + }; + + const request = (method, url, payload, callback) => { + const xmlhttp = new XMLHttpRequest(); + xmlhttp.onreadystatechange = () => { + if (xmlhttp.readyState === XMLHttpRequest.DONE) { + callback(xmlhttp); + } + }; + xmlhttp.open(method, url, true); + xmlhttp.setRequestHeader('Content-Type', 'application/json'); + xmlhttp.setRequestHeader('Accept', 'application/json'); + xmlhttp.send(payload); + }; + + const extractCookie = (cookies, name) => { + for (const cookie of cookies) { + const [cookieName, cookieValue] = cookie.trim().split('='); + if (cookieName === name) { + return cookieValue.trim(); + } + } + return null; + }; + + const getCookie = (name) => { + if (!document.cookie) { + return null; + } + + const cookies = document.cookie.split(';'); + return extractCookie(cookies, name); + }; + + const getUserCtx = () => { + const cookie = getCookie('userCtx'); + if (cookie) { + try { + return JSON.parse(decodeURIComponent(cookie)); + } catch (e) { + console.error('Error parsing cookie', e); + } + } + }; + + const getLocale = (translations) => { + const selectedLocale = getCookie('locale'); + const defaultLocale = document.body.getAttribute('data-default-locale'); + const locale = selectedLocale || defaultLocale; + if (translations[locale]) { + return locale; + } + const validLocales = Object.keys(translations); + if (validLocales.length) { + return validLocales[0]; + } + }; + + const parseTranslations = () => { + const raw = document.body.getAttribute('data-translations'); + return JSON.parse(decodeURIComponent(raw)); + }; + + const replaceTranslationPlaceholders = (text, translateValues) => { + if (!text || !translateValues) { + return text; + } + + try { + const values = JSON.parse(translateValues); + return Object + .entries(values) + .reduce((result, [key, value]) => result.replace(new RegExp(`{{${key}}}`, 'g'), value), text); + } catch (e) { + console.error('Error parsing translation placeholders', e); + return text; + } + }; + + const baseTranslate = (selectedLocale, translations) => { + if (!selectedLocale) { + return console.error('No enabled locales found - not translating'); + } + document + .querySelectorAll('[translate]') + .forEach(elem => { + let text = translations[selectedLocale][elem.getAttribute('translate')]; + const translateValues = elem.getAttribute('translate-values'); + if (translateValues) { + text = replaceTranslationPlaceholders(text, translateValues); + } + elem.innerText = text; + }); + document + .querySelectorAll('[translate-title]') + .forEach(elem => elem.title = translations[selectedLocale][elem.getAttribute('translate-title')]); + }; + + const togglePassword = (passwordInputId, passwordContainerId) => { + const passwordInput = document.getElementById(passwordInputId); + if (!passwordInput) { + return; + } + + const displayType = passwordInput.type === 'password' ? 'text' : 'password'; + passwordInput.type = displayType; + document.getElementById(passwordContainerId)?.classList.toggle('hidden-password'); + }; + + return { + setState, + request, + getCookie, + getUserCtx, + getLocale, + parseTranslations, + baseTranslate, + togglePassword, + }; +})(); diff --git a/api/src/public/login/password-reset.js b/api/src/public/login/password-reset.js new file mode 100644 index 00000000000..737e9408626 --- /dev/null +++ b/api/src/public/login/password-reset.js @@ -0,0 +1,143 @@ +const { + setState, + request, + getLocale, + parseTranslations, + baseTranslate, + togglePassword, + getUserCtx +} = window.AuthUtils; + +let selectedLocale; +let translations; + +const PASSWORD_INPUT_ID = 'password'; +const CONFIRM_PASSWORD_INPUT_ID = 'confirm-password'; +const CURRENT_PASSWORD_INPUT_ID = 'current-password'; + +const checkSession = function() { + const userCtx = getUserCtx(); + if (!userCtx || !userCtx.name) { + // only logged-in users should access password reset page + window.location = '/medic/login'; + } +}; + +const translate = () => { + baseTranslate(selectedLocale, translations); +}; + +const displayPasswordValidationError = (serverResponse) => { + const { error, params } = JSON.parse(serverResponse); + setState(error); + + const errorElement = document.querySelector(`.error.${error}`); + if (params && errorElement) { + errorElement.setAttribute('translate-values', JSON.stringify(params)); + translate(); + } +}; + +const validateRequiredFields = (currentPassword, password, confirmPassword) => { + const missingFields = []; + + if (!currentPassword) { + missingFields.push(translations[selectedLocale]['user.password.current']); + } + if (!password) { + missingFields.push(translations[selectedLocale]['change.password.new.password']); + } + if (!confirmPassword) { + missingFields.push(translations[selectedLocale]['change.password.confirm.password']); + } + + if (missingFields.length > 0) { + return { + isValid: false, + error: 'fields-required', + params: { fields: missingFields.join(', ') } + }; + } + + return { isValid: true }; +}; + +const validatePasswordMatch = (password, confirmPassword) => { + if (password !== confirmPassword) { + return { + isValid: false, + error: 'password-mismatch', + }; + } + return { isValid: true }; +}; + +const submit = function(e) { + e.preventDefault(); + if (document.getElementById('form')?.className === 'loading') { + // debounce double clicks + return; + } + + const currentPassword = document.getElementById(CURRENT_PASSWORD_INPUT_ID)?.value; + const password = document.getElementById(PASSWORD_INPUT_ID)?.value; + const confirmPassword = document.getElementById(CONFIRM_PASSWORD_INPUT_ID)?.value; + + const validations = [ + validateRequiredFields(currentPassword, password, confirmPassword), + validatePasswordMatch(password, confirmPassword) + ]; + + for (const validation of validations) { + if (!validation.isValid) { + displayPasswordValidationError(JSON.stringify({ + error: validation.error, + params: validation.params + })); + return; + } + } + + setState('loading'); + const url = document.getElementById('form')?.action; + const userCtx = getUserCtx(); + + const payload = JSON.stringify({ + username: userCtx.name, + password: password, + currentPassword: currentPassword, + locale: selectedLocale + }); + + request('POST', url, payload, function(xmlhttp) { + if (xmlhttp.status === 302) { + // success - redirect to app + localStorage.setItem('passwordStatus', 'PASSWORD_CHANGED'); + window.location = xmlhttp.response; + } else if (xmlhttp.status === 400) { + // password validation failed + displayPasswordValidationError(xmlhttp.response); + } else { + setState('error'); + console.error('Error updating password', xmlhttp.response); + } + }); +}; + +document.addEventListener('DOMContentLoaded', function() { + translations = parseTranslations(); + selectedLocale = getLocale(translations); + translate(); + checkSession(); + + document.getElementById('update-password')?.addEventListener('click', submit, false); + + const passwordToggle = document.getElementById('password-toggle'); + if (passwordToggle) { + passwordToggle.addEventListener('click', () => { + togglePassword('password', 'password-container'); + togglePassword('confirm-password', 'confirm-password-container'); + togglePassword('current-password', 'current-password-container'); + }, false); + } +}); diff --git a/api/src/public/login/script.js b/api/src/public/login/script.js index 557fc12d213..4e6c3a300b2 100644 --- a/api/src/public/login/script.js +++ b/api/src/public/login/script.js @@ -1,40 +1,34 @@ +const { + setState, + request, + getCookie, + getLocale, + parseTranslations, + baseTranslate, + togglePassword, + getUserCtx +} = window.AuthUtils; + let selectedLocale; let translations; const PASSWORD_INPUT_ID = 'password'; -const setState = function(className) { - document.getElementById('form').className = className; -}; - const setTokenState = className => { document.getElementById('wrapper').className = `has-error ${className}`; }; -const request = function(method, url, payload, callback) { - const xmlhttp = new XMLHttpRequest(); - xmlhttp.onreadystatechange = function() { - if (xmlhttp.readyState === XMLHttpRequest.DONE) { - callback(xmlhttp); - } - }; - xmlhttp.open(method, url, true); - xmlhttp.setRequestHeader('Content-Type', 'application/json'); - xmlhttp.setRequestHeader('Accept', 'application/json'); - xmlhttp.send(payload); -}; - const submit = function(e) { e.preventDefault(); - if (document.getElementById('form').className === 'loading') { + if (document.getElementById('form')?.className === 'loading') { // debounce double clicks return; } setState('loading'); - const url = document.getElementById('form').action; + const url = document.getElementById('form')?.action; const payload = JSON.stringify({ user: getUsername(), - password: document.getElementById(PASSWORD_INPUT_ID).value, + password: document.getElementById(PASSWORD_INPUT_ID)?.value, redirect: getRedirectUrl(), locale: selectedLocale }); @@ -54,7 +48,7 @@ const submit = function(e) { }; const requestTokenLogin = (retry = 20) => { - const url = document.getElementById('tokenLogin').action; + const url = document.getElementById('tokenLogin')?.action; const payload = JSON.stringify({ locale: selectedLocale }); request('POST', url, payload, xmlhttp => { let response = {}; @@ -88,20 +82,19 @@ const requestTokenLogin = (retry = 20) => { const focusOnPassword = function(e) { if (e.keyCode === 13) { e.preventDefault(); - document.getElementById(PASSWORD_INPUT_ID).focus(); + document.getElementById(PASSWORD_INPUT_ID)?.focus(); } }; const focusOnSubmit = function(e) { if (e.keyCode === 13) { - document.getElementById('login').focus(); + document.getElementById('login')?.focus(); } }; const highlightSelectedLocale = function() { const locales = document.getElementsByClassName('locale'); - for (let i = 0; i < locales.length; i++) { - const elem = locales[i]; + for (const elem of locales) { elem.className = (elem.name === selectedLocale) ? 'locale selected' : 'locale'; } }; @@ -114,52 +107,13 @@ const handleLocaleSelection = function(e) { } }; -const getCookie = function(name) { - const cookies = document.cookie && document.cookie.split(';'); - if (cookies) { - for (const cookie of cookies) { - const parts = cookie.trim().split('='); - if (parts[0] === name) { - return parts[1].trim(); - } - } - } -}; - -const getLocale = function() { - const selectedLocale = getCookie('locale'); - const defaultLocale = document.body.getAttribute('data-default-locale'); - const locale = selectedLocale || defaultLocale; - if (translations[locale]) { - return locale; - } - const validLocales = Object.keys(translations); - if (validLocales.length) { - return validLocales[0]; - } - return; -}; - const translate = () => { - if (!selectedLocale) { - return console.error('No enabled locales found - not translating'); - } + baseTranslate(selectedLocale, translations); highlightSelectedLocale(); - document - .querySelectorAll('[translate]') - .forEach(elem => elem.innerText = translations[selectedLocale][elem.getAttribute('translate')]); - document - .querySelectorAll('[translate-title]') - .forEach(elem => elem.title = translations[selectedLocale][elem.getAttribute('translate-title')]); -}; - -const parseTranslations = function() { - const raw = document.body.getAttribute('data-translations'); - return JSON.parse(decodeURIComponent(raw)); }; const getUsername = function() { - return document.getElementById('user').value.toLowerCase().trim(); + return document.getElementById('user')?.value.toLowerCase().trim(); }; const getRedirectUrl = function() { @@ -171,17 +125,6 @@ const getRedirectUrl = function() { } }; -const getUserCtx = function() { - const cookie = getCookie('userCtx'); - if (cookie) { - try { - return JSON.parse(decodeURIComponent(cookie)); - } catch (e) { - console.error('Error parsing cookie', e); - } - } -}; - const checkSession = function() { if (getCookie('login') === 'force') { // require user to login regardless of session state @@ -236,42 +179,36 @@ const checkUnsupportedBrowser = () => { } if (typeof outdatedComponentKey !== 'undefined') { - document.getElementById('unsupported-browser-update').setAttribute('translate', outdatedComponentKey); + document.getElementById('unsupported-browser-update')?.setAttribute('translate', outdatedComponentKey); document.getElementById('unsupported-browser-update').innerText = translations[selectedLocale][outdatedComponentKey]; - document.getElementById('unsupported-browser').classList.remove('hidden'); + document.getElementById('unsupported-browser')?.classList.remove('hidden'); } }; -const togglePassword = () => { - const input = document.getElementById(PASSWORD_INPUT_ID); - input.type = input.type === 'password' ? 'text' : 'password'; - document.getElementById('password-container').classList.toggle('hidden-password'); -}; - document.addEventListener('DOMContentLoaded', function() { translations = parseTranslations(); - selectedLocale = getLocale(); + selectedLocale = getLocale(translations); translate(); - document.getElementById('locale').addEventListener('click', handleLocaleSelection, false); + document.getElementById('locale')?.addEventListener('click', handleLocaleSelection, false); const passwordToggle = document.getElementById('password-toggle'); if (passwordToggle) { - passwordToggle.addEventListener('click', togglePassword, false); + passwordToggle.addEventListener('click', () => togglePassword(PASSWORD_INPUT_ID), false); } if (document.getElementById('tokenLogin')) { requestTokenLogin(); } else { checkSession(); - document.getElementById('login').addEventListener('click', submit, false); + document.getElementById('login')?.addEventListener('click', submit, false); const user = document.getElementById('user'); user.addEventListener('keydown', focusOnPassword, false); user.focus(); - document.getElementById(PASSWORD_INPUT_ID).addEventListener('keydown', focusOnSubmit, false); + document.getElementById(PASSWORD_INPUT_ID)?.addEventListener('keydown', focusOnSubmit, false); if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/service-worker.js'); } diff --git a/api/src/public/login/style.css b/api/src/public/login/style.css index 952de1dc01f..3a9354ca049 100644 --- a/api/src/public/login/style.css +++ b/api/src/public/login/style.css @@ -101,7 +101,14 @@ form { .tokenmissing .error.missing, .tokentimeout .error.timeout, .tokenexpired .error.expired, -.tokenerror .error.unknown +.tokenerror .error.unknown, +.password-weak .error.password-weak, +.password-short .error.password-short, +.password-mismatch .error.password-mismatch, +.password-required .error.password-required, +.password-same .error.password-same, +.current-password-incorrect .error.current-password-incorrect, +.fields-required .error.fields-required { display: block; } diff --git a/api/src/routing.js b/api/src/routing.js index 6f1ce353a56..c6f7ae4ba96 100644 --- a/api/src/routing.js +++ b/api/src/routing.js @@ -298,6 +298,8 @@ app.get(routePrefix + 'login/identity', login.getIdentity); app.postJson(routePrefix + 'login', login.post); app.get(routePrefix + 'login/token/:token?', login.tokenGet); app.postJson(routePrefix + 'login/token/:token?', login.tokenPost); +app.get(routePrefix + 'password-reset', login.getPasswordReset); +app.postJson(routePrefix + 'password-reset', login.resetPassword); app.get(routePrefix + 'privacy-policy', privacyPolicyController.get); // authorization for `_compact`, `_view_cleanup`, `_revs_limit` endpoints is handled by CouchDB diff --git a/api/src/services/cookie.js b/api/src/services/cookie.js index e78ef7c1901..d655e4f734b 100644 --- a/api/src/services/cookie.js +++ b/api/src/services/cookie.js @@ -42,6 +42,10 @@ const extractCookieAttributes = (cookieString) => { }; module.exports = { + clearCookie: (res, name) => { + const options = getCookieOptions({ httpOnly: true }); + res.clearCookie(name, options); + }, get: (req, name) => { const cookies = req.headers && req.headers.cookie; if (!cookies) { diff --git a/api/src/templates/login/index.html b/api/src/templates/login/index.html index 204b7a7b9c0..8b635eb860a 100644 --- a/api/src/templates/login/index.html +++ b/api/src/templates/login/index.html @@ -44,6 +44,7 @@ <% } %>
+ diff --git a/api/src/templates/login/password-reset.html b/api/src/templates/login/password-reset.html new file mode 100644 index 00000000000..62e08d9d573 --- /dev/null +++ b/api/src/templates/login/password-reset.html @@ -0,0 +1,57 @@ + + + + + + {{ branding.name }} + + + + +
+
+ +

+

+
+ + +
+ + +
+ +
+ show-password-ion + hide-password-ion +
+
+ +
+ + +
+ +

+

+

+

+

+

+ + +
+
+
+ + + + \ No newline at end of file diff --git a/api/src/templates/login/token-login.html b/api/src/templates/login/token-login.html index 9b26748fc3d..b942c78f82c 100644 --- a/api/src/templates/login/token-login.html +++ b/api/src/templates/login/token-login.html @@ -40,6 +40,7 @@
+ diff --git a/api/tests/mocha/controllers/login.spec.js b/api/tests/mocha/controllers/login.spec.js index 9964dfc8958..baa572543c8 100644 --- a/api/tests/mocha/controllers/login.spec.js +++ b/api/tests/mocha/controllers/login.spec.js @@ -10,7 +10,7 @@ const auth = require('../../../src/auth'); const cookie = require('../../../src/services/cookie'); const branding = require('../../../src/services/branding'); const rateLimit = require('../../../src/services/rate-limit'); -const db = require('../../../src/db').medic; +const db = require('../../../src/db'); const translations = require('../../../src/translations'); const privacyPolicy = require('../../../src/services/privacy-policy'); const config = require('../../../src/config'); @@ -40,6 +40,7 @@ describe('login controller', () => { query: {}, body: {}, hostname: 'xx.app.medicmobile.org', + protocol: 'http', headers: {cookie: ''} }; res = { @@ -60,6 +61,8 @@ describe('login controller', () => { sinon.stub(rateLimit, 'isLimited').returns(false); sinon.stub(serverUtils, 'rateLimited').resolves(); + sinon.stub(db.medic, 'get'); + sinon.stub(db.users, 'get'); }); afterEach(() => { @@ -154,6 +157,7 @@ describe('login controller', () => { sinon.stub(translations, 'getEnabledLocales').resolves([]); const linkResources = '; rel=preload; as=style, ' + '; rel=preload; as=script, ' + + '; rel=preload; as=script, ' + '; rel=preload; as=script'; const brandingGet = sinon.stub(branding, 'get').resolves(DEFAULT_BRANDING); const send = sinon.stub(res, 'send'); @@ -176,6 +180,7 @@ describe('login controller', () => { it('when branding doc missing send login page', () => { const linkResources = '; rel=preload; as=style, ' + '; rel=preload; as=script, ' + + '; rel=preload; as=script, ' + '; rel=preload; as=script'; const brandingGet = sinon.stub(branding, 'get').resolves(DEFAULT_BRANDING); sinon.stub(translations, 'getEnabledLocales').resolves([]); @@ -200,6 +205,7 @@ describe('login controller', () => { sinon.stub(res, 'cookie').returns(res); const readFile = sinon.stub(fs.promises, 'readFile').resolves('file content'); sinon.stub(config, 'translate').returns('TRANSLATED VALUE.'); + sinon.stub(users, 'getUserDoc').resolves(); const template = sinon.stub(_, 'template').returns(sinon.stub()); sinon.stub(branding, 'get').resolves(DEFAULT_BRANDING); return controller.get(req, res) // first request @@ -218,12 +224,14 @@ describe('login controller', () => { it('hides locale selector when there is only one option', () => { const linkResources = '; rel=preload; as=style, ' + '; rel=preload; as=script, ' + + '; rel=preload; as=script, ' + '; rel=preload; as=script'; const setHeader = sinon.stub(res, 'setHeader'); sinon.stub(translations, 'getEnabledLocales').resolves([{ code: 'en', name: 'English' }]); sinon.stub(branding, 'get').resolves(DEFAULT_BRANDING); const send = sinon.stub(res, 'send'); sinon.stub(fs.promises, 'readFile').resolves('LOGIN PAGE GOES HERE. {{ locales.length }}'); + sinon.stub(users, 'getUserDoc').resolves(); sinon.stub(config, 'translate').returns('TRANSLATED VALUE.'); sinon.stub(cookie, 'get').returns('en'); return controller.get(req, res).then(() => { @@ -276,6 +284,127 @@ describe('login controller', () => { }); }); + describe('passwordReset', () => { + it('getPasswordReset should render password reset page', () => { + sinon.stub(translations, 'getEnabledLocales').resolves([]); + const linkResources = '; rel=preload; as=style, ' + + '; rel=preload; as=script, ' + + '; rel=preload; as=script'; + const brandingGet = sinon.stub(branding, 'get').resolves(DEFAULT_BRANDING); + const send = sinon.stub(res, 'send'); + const setHeader = sinon.stub(res, 'setHeader'); + sinon.stub(fs.promises, 'readFile').resolves('PASSWORD RESET PAGE GOES HERE. {{ translations }}'); + sinon.stub(config, 'getTranslations').returns({ en: { password: 'Password' } }); + return controller.getPasswordReset(req, res).then(() => { + chai.expect(brandingGet.callCount).to.equal(1); + chai.expect(send.callCount).to.equal(1); + chai.expect(send.args[0][0]) + .to.equal('PASSWORD RESET PAGE GOES HERE. %7B%22en%22%3A%7B%22password%22%3A%22Password%22%7D%7D'); + chai.expect(setHeader.callCount).to.equal(1); + chai.expect(setHeader.args[0][0]).to.equal('Link'); + chai.expect(setHeader.args[0][1]).to.equal(linkResources); + chai.expect(fs.promises.readFile.callCount).to.equal(1); + chai.expect(translations.getEnabledLocales.callCount).to.equal(1); + }); + }); + + it('should return 429 when rate limited', () => { + rateLimit.isLimited.returns(true); + return controller.resetPassword(req, res).then(() => { + chai.expect(rateLimit.isLimited.callCount).to.equal(1); + chai.expect(rateLimit.isLimited.args[0][0]).to.equal(req); + chai.expect(serverUtils.rateLimited.callCount).to.equal(1); + }); + }); + + it('should return 400 if new password is invalid', () => { + req.body = { + username: 'user1', + currentPassword: 'current', + password: 'weak', + locale: 'en' + }; + + const status = sinon.stub(res, 'status').returns(res); + const json = sinon.stub(res, 'json').returns(res); + + return controller.resetPassword(req, res).then(() => { + chai.expect(status.callCount).to.equal(1); + chai.expect(status.args[0][0]).to.equal(400); + chai.expect(json.callCount).to.equal(1); + chai.expect(json.args[0][0]).to.deep.equal({ + error: 'password-short', + params: { minimum: 8 } + }); + }); + }); + + it('should reset password when it is valid', () => { + req.body = { + username: 'sharon', + currentPassword: 'oldPass', + password: 'newPass123', + locale: 'en' + }; + + const postResponse = { + status: 200, + headers: { getSetCookie: () => [ 'AuthSession=abc;' ] } + }; + const post = sinon.stub(request, 'post').resolves(postResponse); + const send = sinon.stub(res, 'send'); + const status = sinon.stub(res, 'status').returns(res); + const cookie = sinon.stub(res, 'cookie').returns(res); + + const userCtx = { name: 'sharon', roles: [ 'project-stuff' ] }; + const getUserCtx = sinon.stub(auth, 'getUserCtx').resolves(userCtx); + + const userDoc = { + name: 'sharon', + type: 'user', + password: 'oldPass' + }; + sinon.stub(users, 'getUserDoc').resolves(userDoc); + sinon.stub(users, 'updateUser').resolves({ + user: { id: 'org.couchdb.user:sharon' }, + 'user-settings': { id: 'org.couchdb.user:sharon' } + }); + sinon.stub(request, 'get').resolves({ + status: 200, + body: { userCtx: { name: 'sharon' } } + }); + + return controller.resetPassword(req, res).then(() => { + chai.expect(status.callCount).to.equal(1); + chai.expect(status.args[0][0]).to.equal(302); + chai.expect(send.args[0][0]).to.equal('/'); + chai.expect(post.callCount).to.equal(1); + chai.expect(post.args[0][0].url).to.equal('http://test.com:1234/_session'); + chai.expect(post.args[0][0].body.name).to.equal('sharon'); + chai.expect(post.args[0][0].body.password).to.equal('newPass123'); + chai.expect(getUserCtx.callCount).to.equal(1); + chai.expect(getUserCtx.args[0][0].headers.Cookie).to.equal('AuthSession=abc;'); + chai.expect(cookie.callCount).to.equal(3); + chai.expect(cookie.args[0][0]).to.equal('AuthSession'); + chai.expect(cookie.args[0][1]).to.equal('abc'); + chai.expect(cookie.args[1][0]).to.equal('userCtx'); + chai.expect(cookie.args[1][1]).to.equal(JSON.stringify(userCtx)); + chai.expect(cookie.args[2][0]).to.equal('locale'); + chai.expect(cookie.args[2][1]).to.equal('en'); + chai.expect(users.updateUser.callCount).to.equal(1); + chai.expect(users.updateUser.args[0]).to.deep.equal([ + 'sharon', + { + password: 'newPass123', + password_change_required: false + }, + true, + `${req.protocol}://${req.hostname}` + ]); + }); + }); + }); + describe('get login/token', () => { it('should render the token login page', () => { sinon.stub(translations, 'getEnabledLocales').resolves([]); @@ -390,6 +519,7 @@ describe('login controller', () => { sinon.stub(res, 'send').returns(res); sinon.stub(res, 'cookie'); sinon.stub(auth, 'getUserSettings').resolves({}); + sinon.stub(users, 'getUserDoc').resolves(); const userCtx = { name: 'user_name', roles: [ 'project-stuff' ] }; sinon.stub(auth, 'getUserCtx') .onCall(0).rejects({ code: 401 }) @@ -431,6 +561,7 @@ describe('login controller', () => { sinon.stub(res, 'status').returns(res); sinon.stub(res, 'cookie'); sinon.stub(res, 'send'); + sinon.stub(users, 'getUserDoc').resolves(); sinon.stub(auth, 'getUserSettings').resolves({}); const userCtx = { name: 'user_name', roles: [ 'roles' ] }; sinon.stub(auth, 'getUserCtx') @@ -497,11 +628,16 @@ describe('login controller', () => { }); it('returns invalid credentials', () => { + sinon.stub(users, 'getUserDoc').resolves(); req.body = { user: 'sharon', password: 'p4ss' }; - const post = sinon.stub(request, 'post').resolves({ status: 401 }); + const post = sinon.stub(request, 'post').rejects({ + status: 401, + error: 'Not logged in' + }); const status = sinon.stub(res, 'status').returns(res); const json = sinon.stub(res, 'json').returns(res); return controller.post(req, res).then(() => { + chai.expect(request.post.callCount).to.equal(1); chai.expect(post.callCount).to.equal(1); chai.expect(status.callCount).to.equal(1); chai.expect(status.args[0][0]).to.equal(401); @@ -531,6 +667,7 @@ describe('login controller', () => { sinon.stub(res, 'status').returns(res); sinon.stub(res, 'send').returns(res); sinon.stub(res, 'cookie'); + sinon.stub(users, 'getUserDoc').resolves(); sinon.stub(auth, 'getUserCtx').rejects({ code: 401 }); auth.getUserCtx.onCall(9).resolves({ name: 'shazza', roles: [ 'project-stuff' ] }); @@ -613,6 +750,11 @@ describe('login controller', () => { const userCtx = { name: 'shazza', roles: [ 'project-stuff' ] }; const getUserCtx = sinon.stub(auth, 'getUserCtx').resolves(userCtx); sinon.stub(auth, 'getUserSettings').resolves({}); + sinon.stub(users, 'getUserDoc').resolves({ + name: 'sharon', + type: 'user', + password_change_required: false + }); return controller.post(req, res).then(() => { chai.expect(post.callCount).to.equal(1); chai.expect(post.args[0][0].url).to.equal('http://test.com:1234/_session'); @@ -640,6 +782,44 @@ describe('login controller', () => { }); }); + it('logs in successfully and redirects to password-reset for new users', () => { + req.body = { user: 'sharon', password: 'p4ss', locale: 'es' }; + const postResponse = { + status: 200, + headers: { getSetCookie: () => [ 'AuthSession=abc;' ] } + }; + const post = sinon.stub(request, 'post').resolves(postResponse); + const send = sinon.stub(res, 'send'); + const status = sinon.stub(res, 'status').returns(res); + const cookie = sinon.stub(res, 'cookie').returns(res); + const userCtx = { name: 'shazza', roles: [ 'project-stuff' ] }; + const getUserCtx = sinon.stub(auth, 'getUserCtx').resolves(userCtx); + sinon.stub(users, 'getUserDoc').resolves({ + name: 'sharon', + type: 'user', + password_change_required: true + }); + sinon.stub(auth, 'getUserSettings').resolves({}); + return controller.post(req, res).then(() => { + chai.expect(post.callCount).to.equal(1); + chai.expect(post.args[0][0].url).to.equal('http://test.com:1234/_session'); + chai.expect(post.args[0][0].body.name).to.equal('sharon'); + chai.expect(post.args[0][0].body.password).to.equal('p4ss'); + chai.expect(getUserCtx.callCount).to.equal(1); + chai.expect(getUserCtx.args[0][0].headers.Cookie).to.equal('AuthSession=abc;'); + chai.expect(status.callCount).to.equal(1); + chai.expect(status.args[0][0]).to.equal(302); + chai.expect(send.args[0][0]).to.deep.equal('/medic/password-reset'); + chai.expect(cookie.callCount).to.equal(2); + chai.expect(cookie.args[0][0]).to.equal('userCtx'); + chai.expect(cookie.args[0][1]).to.equal(JSON.stringify(userCtx)); + chai.expect(cookie.args[0][2]).to.deep.equal({ sameSite: 'lax', secure: false, maxAge: 31536000000 }); + chai.expect(cookie.args[1][0]).to.equal('locale'); + chai.expect(cookie.args[1][1]).to.equal('es'); + chai.expect(cookie.args[1][2]).to.deep.equal({ sameSite: 'lax', secure: false, maxAge: 31536000000 }); + }); + }); + it('sets user settings and cookie to default when no locale selected', () => { req.body = { user: 'sharon', password: 'p4ss' }; const postResponse = { @@ -650,6 +830,7 @@ describe('login controller', () => { sinon.stub(res, 'send'); sinon.stub(res, 'status').returns(res); const cookie = sinon.stub(res, 'cookie').returns(res); + sinon.stub(users, 'getUserDoc').resolves(); sinon.stub(auth, 'getUserCtx').resolves({ name: 'shazza', roles: [ 'project-stuff' ] }); sinon.stub(auth, 'hasAllPermissions').returns(false); sinon.stub(auth, 'getUserSettings').resolves({ }); @@ -675,6 +856,7 @@ describe('login controller', () => { sinon.stub(res, 'send'); sinon.stub(res, 'status').returns(res); const cookie = sinon.stub(res, 'cookie').returns(res); + sinon.stub(users, 'getUserDoc').resolves(); sinon.stub(auth, 'getUserCtx').resolves({ name: 'shazza', roles: [ 'project-stuff' ] }); sinon.stub(auth, 'hasAllPermissions').returns(false); sinon.stub(auth, 'getUserSettings').resolves({ language: 'fr' }); @@ -703,6 +885,7 @@ describe('login controller', () => { const userCtx = { name: 'shazza', roles: [ 'project-stuff' ] }; const getUserCtx = sinon.stub(auth, 'getUserCtx').resolves(userCtx); const hasAllPermissions = sinon.stub(auth, 'hasAllPermissions').returns(true); + sinon.stub(users, 'getUserDoc').resolves(); sinon.stub(auth, 'getUserSettings').resolves({ language: 'es' }); return controller.post(req, res).then(() => { chai.expect(post.callCount).to.equal(1); @@ -729,6 +912,7 @@ describe('login controller', () => { const getUserCtx = sinon.stub(auth, 'getUserCtx').resolves(userCtx); roles.isOnlineOnly.returns(true); sinon.stub(auth, 'hasAllPermissions').returns(true); + sinon.stub(users, 'getUserDoc').resolves(); sinon.stub(auth, 'getUserSettings').resolves({ language: 'es' }); return controller.post(req, res).then(() => { chai.expect(post.callCount).to.equal(1); @@ -758,6 +942,7 @@ describe('login controller', () => { sinon.stub(res, 'status').returns(res); sinon.stub(users, 'createAdmin').resolves(); const userCtx = { name: 'shazza', roles: [ '_admin' ] }; + sinon.stub(users, 'getUserDoc').resolves(); sinon.stub(auth, 'getUserCtx').resolves(userCtx); roles.isOnlineOnly.returns(true); sinon.stub(roles, 'isDbAdmin').returns(true); diff --git a/api/tests/mocha/generate-service-worker.spec.js b/api/tests/mocha/generate-service-worker.spec.js index 169fac460b9..08a6f129dc8 100644 --- a/api/tests/mocha/generate-service-worker.spec.js +++ b/api/tests/mocha/generate-service-worker.spec.js @@ -20,6 +20,7 @@ describe('generate service worker', () => { sinon.stub(resources, 'staticPath').value('/absolute/path/to/build/static/'); sinon.stub(resources, 'webappPath').value('/absolute/path/to/build/static/webapp/'); sinon.stub(loginController, 'renderLogin'); + sinon.stub(loginController, 'renderPasswordReset'); sinon.stub(db.medic, 'get'); sinon.stub(db.medic, 'put'); sinon.stub(extensionLibsService, 'getAll'); @@ -46,6 +47,7 @@ describe('generate service worker', () => { it('should generate the service worker file and update the service worker meta doc', async () => { loginController.renderLogin.resolves('loginpage html'); + loginController.renderPasswordReset.resolves('passwordresetpage html'); extensionLibsService.getAll.resolves([{ name: 'bar.js', data: 'barcode' }]); sinon.stub(workbox, 'generateSW').returns(); db.medic.get.resolves({ _id: 'service-worker-meta' }); @@ -84,6 +86,7 @@ describe('generate service worker', () => { templatedURLs: { '/': [ 'webapp/index.html' ], '/medic/login': 'loginpage html', + '/medic/password-reset': 'passwordresetpage html', '/medic/_design/medic/_rewrite/': [ 'webapp/appcache-upgrade.html' ], diff --git a/api/tests/mocha/services/cookie.spec.js b/api/tests/mocha/services/cookie.spec.js index 75802505e78..5f93443e8e0 100644 --- a/api/tests/mocha/services/cookie.spec.js +++ b/api/tests/mocha/services/cookie.spec.js @@ -12,6 +12,7 @@ describe('cookie service', () => { service = rewire('../../../src/services/cookie'); res = { cookie: sinon.stub(), + clearCookie: sinon.stub(), }; }); @@ -128,6 +129,39 @@ describe('cookie service', () => { }); }); + describe('clearCookie', () => { + it('should clear cookie with correct security options', () => { + sinon.stub(process, 'env').value({}); + const cookieName = 'testCookie'; + service.clearCookie(res, cookieName); + chai.expect(res.clearCookie.callCount).to.equal(1); + chai.expect(res.clearCookie.args[0]).to.deep.equal([ + 'testCookie', + { + sameSite: 'lax', + secure: false, + httpOnly: true + } + ]); + }); + + it('should clear cookie with secure option in production environment', () => { + sinon.stub(process, 'env').value({ NODE_ENV: 'production' }); + service = rewire('../../../src/services/cookie'); + const cookieName = 'testCookie'; + service.clearCookie(res, cookieName); + chai.expect(res.clearCookie.callCount).to.equal(1); + chai.expect(res.clearCookie.args[0]).to.deep.equal([ + 'testCookie', + { + sameSite: 'lax', + secure: true, + httpOnly: true + } + ]); + }); + }); + describe('setSession', () => { it('should work with simple cookie', () => { const cookieString = 'AuthSession=sessionID'; diff --git a/config/default/app_settings.json b/config/default/app_settings.json index 07d76c5b722..86e1aa2425b 100644 --- a/config/default/app_settings.json +++ b/config/default/app_settings.json @@ -283,6 +283,7 @@ "can_view_old_navigation": [], "can_default_facility_filter": [], "can_have_multiple_places": [], + "can_skip_password_change": [], "can_export_devices_details": [ "national_admin" ] diff --git a/config/demo/app_settings.json b/config/demo/app_settings.json index 645ae7d2896..7be6b55d72b 100644 --- a/config/demo/app_settings.json +++ b/config/demo/app_settings.json @@ -276,7 +276,8 @@ "can_upgrade": [ "program_officer" ], - "can_view_old_navigation": [] + "can_view_old_navigation": [], + "can_skip_password_change": [] }, "uhc": { "contacts_default_sort": "", diff --git a/shared-libs/user-management/src/index.js b/shared-libs/user-management/src/index.js index 86eecbdf20f..8863aea0e5a 100644 --- a/shared-libs/user-management/src/index.js +++ b/shared-libs/user-management/src/index.js @@ -17,7 +17,8 @@ module.exports = (sourceConfig, sourceDb, sourceDataContext) => { bulkUploadLog, roles, tokenLogin, - users + users, + validatePassword: users.validatePassword }; }; diff --git a/shared-libs/user-management/src/users.js b/shared-libs/user-management/src/users.js index 8c53b6750d5..09a326f16ea 100644 --- a/shared-libs/user-management/src/users.js +++ b/shared-libs/user-management/src/users.js @@ -198,15 +198,21 @@ const validateNewUsername = username => { ]); }; -const createUser = (data, response) => { - const user = getUserUpdates(data.username, data); - user._id = createID(data.username); - return db.users.put(user).then(body => { - response.user = { - id: body.id, - rev: body.rev - }; - }); +const createUser = async (data, response) => { + const user = { + name: data.username, + type: 'user' + }; + const updatedUser = await getUserUpdates(user, data, true); + updatedUser._id = createID(data.username); + + return db.users.put(updatedUser) + .then(body => { + response.user = { + id: body.id, + rev: body.rev + }; + }); }; const hasUserCreateFlag = doc => doc?.user_for_contact?.create; @@ -423,7 +429,7 @@ const getCommonFieldsUpdates = (userDoc, data) => { if (data.roles) { userDoc.roles = data.roles; } - + if (!_.isUndefined(data.place)) { userDoc.facility_id = data.facility_id; } @@ -452,23 +458,33 @@ const getSettingsUpdates = (username, data) => { return settings; }; -const getUserUpdates = (username, data) => { +const isPasswordChangeRequired = (user, data, fullAccess) => { + if (!fullAccess || tokenLogin.shouldEnableTokenLogin(data)) { + return false; + } + + const userRoles = data.roles || user?.roles; + return !roles.hasAllPermissions(userRoles, ['can_skip_password_change']); +}; + +const getUserUpdates = (user, data, fullAccess = false) => { const ignore = ['type', 'place', 'contact']; + const updatedUser = { ...user, type: 'user' }; - const user = { - name: username, - type: 'user' - }; + if (data.password) { + updatedUser.password_change_required = data.password_change_required === false ? false : + isPasswordChangeRequired(updatedUser, data, fullAccess); + } USER_EDITABLE_FIELDS.forEach(key => { if (!_.isUndefined(data[key]) && ignore.indexOf(key) === -1) { - user[key] = data[key]; + updatedUser[key] = data[key]; } }); - getCommonFieldsUpdates(user, data); + getCommonFieldsUpdates(updatedUser, data); - return user; + return updatedUser; }; const createID = name => USER_PREFIX + name; @@ -491,7 +507,7 @@ const deleteUser = id => { }; const validatePassword = (password) => { - if (password.length < PASSWORD_MINIMUM_LENGTH) { + if (password?.length < PASSWORD_MINIMUM_LENGTH) { return error400( `The password must be at least ${PASSWORD_MINIMUM_LENGTH} characters long.`, 'password.length.minimum', @@ -542,15 +558,16 @@ const missingFields = data => { return required.filter(prop => isInvalidProp(prop)); }; -const getUpdatedUserDoc = async (username, data) => getUserDoc(username, 'users') - .then(doc => { - return { - ...doc, - ...getUserUpdates(username, data), - _id: createID(username) - }; - }); - +const getUpdatedUserDoc = async (username, data, fullAccess) => { + return getUserDoc(username, 'users') + .then(async doc => { + return { + ...doc, + ...(await getUserUpdates(doc, data, fullAccess)), + _id: createID(username) + }; + }); +}; const getUpdatedSettingsDoc = (username, data) => getUserDoc(username, 'medic') .then(doc => { @@ -561,14 +578,14 @@ const getUpdatedSettingsDoc = (username, data) => getUserDoc(username, 'medic') }; }); -const isDbAdmin = user => { +const isDbAdmin = username => { return couchSettings .getCouchConfig('admins') - .then(admins => admins && !!admins[user.name]); + .then(admins => admins && !!admins[username]); }; const saveUserUpdates = async (user) => { - if (user.password && await isDbAdmin(user)) { + if (user.password && await isDbAdmin(user.name)) { throw error400('Admin passwords must be changed manually in the database'); } const savedDoc = await db.users.put(user); @@ -954,6 +971,7 @@ module.exports = { const facilities = await facility.list([user]); return mapUser(user, userSettings, facilities); }, + getUserDoc: (username) => getUserDoc(username, 'users'), getUserSettings, /* eslint-disable max-len */ /** @@ -1132,7 +1150,7 @@ module.exports = { hydratePayload(data); const [user, userSettings] = await Promise.all([ - getUpdatedUserDoc(username, data), + getUpdatedUserDoc(username, data, fullAccess), getUpdatedSettingsDoc(username, data), ]); @@ -1159,13 +1177,15 @@ module.exports = { */ resetPassword: async (username) => { const password = passwords.generate(); - const user = await getUpdatedUserDoc(username, { password }); + const user = await getUpdatedUserDoc(username, { password }, true); await saveUserUpdates(user); return password; }, validateNewUsername, + validatePassword, + /** * Parses a CSV of users to an array of objects. * diff --git a/shared-libs/user-management/test/unit/users.spec.js b/shared-libs/user-management/test/unit/users.spec.js index 2e4e574d250..27d34489380 100644 --- a/shared-libs/user-management/test/unit/users.spec.js +++ b/shared-libs/user-management/test/unit/users.spec.js @@ -140,23 +140,27 @@ describe('Users service', () => { describe('getUserUpdates', () => { - it('enforces name field based on id', () => { + it('enforces name field based on id', async () => { const data = { name: 'sam', email: 'john@gmail.com' }; - const user = service.__get__('getUserUpdates')('john', data); - chai.expect(user.name ).to.equal('john'); + const user = { + name: 'john', + type: 'user' + }; + const updatedUser = await service.__get__('getUserUpdates')(user, data); + chai.expect(updatedUser.name ).to.equal('john'); }); - it('reassigns place and contact fields', () => { + it('reassigns place and contact fields', async () => { const data = { place: 'abc', contact: 'xyz', facility_id: ['abc'], contact_id: 'xyz' }; - const user = service.__get__('getUserUpdates')('john', data); + const user = await service.__get__('getUserUpdates')('john', data); chai.expect(user.place).to.equal(undefined); chai.expect(user.contact).to.equal(undefined); chai.expect(user.facility_id).to.deep.equal(['abc']); @@ -705,6 +709,35 @@ describe('Users service', () => { }); }); + describe('getUserDoc', () => { + const userId = 'org.couchdb.user:steve'; + const userDoc = { + _id: userId, + name: 'steve', + type: 'user', + roles: ['district_admin'] + }; + + it('return user document from users database', async () => { + db.users.get.resolves(userDoc); + + const result = await service.getUserDoc('steve'); + + chai.expect(result).to.deep.equal(userDoc); + chai.expect(db.users.get.calledOnce).to.be.true; + chai.expect(db.users.get.args[0][0]).to.equal(userId); + }); + + it('should throw error when user doc not found', () => { + db.users.get.rejects({ status: 404 }); + + return service.getUserDoc('steve') + .catch(err => { + chai.expect(err.message).to.equal('Failed to find user with name [steve] in the [users] database.'); + }); + }); + }); + describe('getUserSettings', () => { it('returns medic user doc with facility from couchdb user doc', () => { @@ -1301,6 +1334,41 @@ describe('Users service', () => { }); describe('createUser', () => { + it('should set password_change_required to true for new user creation', () => { + const data = { + username: 'newuser', + password: COMPLEX_PASSWORD, + place: 'x', + contact: { parent: 'x' }, + type: 'national-manager' + }; + + service.__set__('validateNewUsername', sinon.stub().resolves()); + service.__set__('createPlace', sinon.stub().resolves()); + service.__set__('setContactParent', sinon.stub().resolves()); + service.__set__('createContact', sinon.stub().resolves()); + service.__set__('storeUpdatedPlace', sinon.stub().resolves()); + service.__set__('createUserSettings', sinon.stub().resolves()); + sinon.stub(roles, 'hasAllPermissions').returns(false); + + couchSettings.getCouchConfig.resolves({ + admin1: 'password_1', + admin2: 'password_2' + }); + + db.users.put.resolves({ id: 'org.couchdb.user:newuser' }); + db.medic.put.resolves({ id: 'org.couchdb.user:newuser' }); + + return service.createUser(data).then(() => { + chai.expect(db.users.put.callCount).to.equal(1); + chai.expect(db.users.put.args[0][0]).to.deep.include({ + name: 'newuser', + type: 'user', + password: COMPLEX_PASSWORD, + password_change_required: true + }); + }); + }); it('returns error if missing fields', () => { return service.createUser({}) @@ -1617,6 +1685,7 @@ describe('Users service', () => { .withArgs('app_url').returns(''); sinon.stub(roles, 'isOffline').returns(false); + sinon.stub(roles, 'hasAllPermissions').returns(false); const users = [{ username: 'sally', @@ -1976,6 +2045,7 @@ describe('Users service', () => { const usersPut = db.users.put; service.__set__('validateNewUsername', sinon.stub().resolves()); service.__set__('storeUpdatedPlace', sinon.stub().resolves()); + sinon.stub(roles, 'hasAllPermissions').returns(false); sinon.stub(places, 'getPlace').resolves({ _id: 'foo' }); medicGet.withArgs('user1') .onFirstCall().rejects({ status: 404 }) @@ -2285,13 +2355,18 @@ describe('Users service', () => { type: 'user', _id: 'org.couchdb.user:x', name: 'x', - password: 'password.123' + password: 'password.123', + password_change_required: false }]]); - chai.expect(roles.hasAllPermissions.args).to.deep.equal([[['national-manager'], ['can_have_multiple_places']]]); + chai.expect(roles.hasAllPermissions.args).to.deep.equal([ + [['national-manager'], ['can_have_multiple_places']], + [['national-manager'], ['can_skip_password_change']] + ]); }); it('succeeds without permission for single facility', async () => { service.__set__('validateNewUsername', sinon.stub().resolves()); + sinon.stub(roles, 'hasAllPermissions').returns(false); sinon.stub(places, 'placesExist').resolves(); sinon.stub(people, 'isAPerson').returns(true); db.medic.put.resolves({ id: 'success' }); @@ -2323,7 +2398,8 @@ describe('Users service', () => { type: 'user', _id: 'org.couchdb.user:x', name: 'x', - password: 'password.123' + password: 'password.123', + password_change_required: true }]]); }); }); @@ -2772,8 +2848,14 @@ describe('Users service', () => { const data = { place: ['x', 'y', 'z'] }; - db.medic.get.resolves({ roles: ['a'] }); - db.users.get.resolves({ roles: ['a'] }); + const user = { + _id: 'org.couchdb.user:paul', + name: 'paul', + type: 'user', + roles: ['a'] + }; + db.medic.get.resolves(user); + db.users.get.resolves(user); sinon.stub(places, 'placesExist').resolves(); sinon.stub(roles, 'hasAllPermissions').returns(true); db.medic.put.resolves({}); @@ -2970,6 +3052,7 @@ describe('Users service', () => { db.medic.put.resolves({}); db.users.put.resolves({}); sinon.stub(roles, 'isOffline').withArgs(['rambler']).returns(false); + sinon.stub(roles, 'hasAllPermissions').returns(false); return service.updateUser('paul', data, true).then(() => { chai.expect(db.medic.put.callCount).to.equal(1); const settings = db.medic.put.args[0][0]; @@ -3044,7 +3127,10 @@ describe('Users service', () => { const data = { language: 'es' }; - db.users.get.resolves({}); + db.users.get.resolves({ + name: 'paul', + type: 'user' + }); db.medic.get.resolves({}); const medicPut = db.medic.put.resolves({}); const usersPut = db.users.put.resolves({}); @@ -3068,7 +3154,10 @@ describe('Users service', () => { const data = { language: 'es' }; - db.users.get.resolves({}); + db.users.get.resolves({ + name: 'paul', + type: 'user' + }); db.medic.get.resolves({}); const medicPut = db.medic.put.resolves({}); const usersPut = db.users.put.resolves({}); @@ -3095,7 +3184,11 @@ describe('Users service', () => { admin2: 'password_2', }); - db.users.get.resolves({}); + db.users.get.resolves({ + name: 'admin2', + type: 'user', + roles: ['_admin'] + }); db.medic.get.resolves({}); db.medic.put.resolves({}); db.users.put.resolves({}); @@ -3108,10 +3201,9 @@ describe('Users service', () => { chai.expect(e.code).to.equal(400); chai.expect(db.medic.put.callCount).to.equal(0); chai.expect(db.users.put.callCount).to.equal(0); - chai.expect(couchSettings.getCouchConfig.calledOnce).to.be.true; + chai.expect(couchSettings.getCouchConfig.callCount).to.equal(1); chai.expect(couchSettings.getCouchConfig.args[0]).to.deep.equal(['admins']); } - }); it('should update admin when no password is sent', async () => { @@ -3120,7 +3212,10 @@ describe('Users service', () => { admin1: 'password_1', admin2: 'password_2', }); - db.users.get.resolves({}); + db.users.get.resolves({ + name: 'admin2', + type: 'user' + }); db.medic.get.resolves({}); db.medic.put.resolves({}); db.users.put.resolves({}); @@ -3149,7 +3244,10 @@ describe('Users service', () => { admin1: 'password_1', admin2: 'password_2', }); - db.users.get.resolves({}); + db.users.get.resolves({ + name: 'anne', + type: 'user' + }); db.medic.get.resolves({}); db.medic.put.resolves({}); db.users.put.resolves({}); @@ -3168,10 +3266,58 @@ describe('Users service', () => { name: 'anne', type: 'user', password: COMPLEX_PASSWORD, + password_change_required: true }); chai.expect(couchSettings.getCouchConfig.callCount).to.equal(1); chai.expect(couchSettings.getCouchConfig.args[0]).to.deep.equal(['admins']); }); + + it('should set password_change_required to true when admin updates user password', async () => { + const data = { password: COMPLEX_PASSWORD }; + sinon.stub(roles, 'hasAllPermissions').returns(false); + couchSettings.getCouchConfig.resolves({ + admin1: 'password_1', + admin2: 'password_2', + }); + db.users.get.resolves({ + name: 'user', + type: 'user', + roles: ['district_admin'] + }); + db.medic.get.resolves({}); + db.medic.put.resolves({}); + db.users.put.resolves({}); + + await service.updateUser('user', data, true); + + chai.expect(db.users.put.callCount).to.equal(1); + chai.expect(db.users.put.args[0][0]).to.deep.include({ + name: 'user', + password: COMPLEX_PASSWORD, + password_change_required: true + }); + }); + + it('should set password_change_required to false when user changes their own password', async () => { + const data = { password: COMPLEX_PASSWORD }; + db.users.get.resolves({ + name: 'user', + type: 'user', + roles: ['district_admin'] + }); + db.medic.get.resolves({}); + db.medic.put.resolves({}); + db.users.put.resolves({}); + + await service.updateUser('user', data, false); + + chai.expect(db.users.put.callCount).to.equal(1); + chai.expect(db.users.put.args[0][0]).to.deep.include({ + name: 'user', + password: COMPLEX_PASSWORD, + password_change_required: false + }); + }); }); describe('validateNewUsername', () => { @@ -3364,6 +3510,7 @@ describe('Users service', () => { .withArgs('app_url').returns(''); sinon.stub(roles, 'isOffline').returns(false); + sinon.stub(roles, 'hasAllPermissions').returns(false); const user = { username: 'sally', @@ -3553,6 +3700,7 @@ describe('Users service', () => { const updates = { token_login: true, phone: '+40 755 89-89-89' }; db.medic.get.onFirstCall().resolves({ _id: 'org.couchdb.user:sally', + name: 'sally', type: 'user-settings', roles: ['a', 'b', 'mm-online'], phone: '123', @@ -3566,6 +3714,7 @@ describe('Users service', () => { }); db.users.get.onFirstCall().resolves({ _id: 'org.couchdb.user:sally', + name: 'sally', type: 'user', roles: ['a', 'b', 'mm-online'], }); @@ -3725,6 +3874,7 @@ describe('Users service', () => { describe('resetPassword', () => { it('should reset password for valid user', async () => { const expectedPassword = 'newpassword'; + sinon.stub(roles, 'hasAllPermissions').returns(false); sinon .stub(passwords, 'generate') .returns(expectedPassword); @@ -3742,11 +3892,12 @@ describe('Users service', () => { chai.expect(db.users.get.callCount).to.equal(1); chai.expect(db.users.get.args[0]).to.deep.equal(['org.couchdb.user:sally']); chai.expect(db.users.put.callCount).to.equal(1); - chai.expect(db.users.put.args[0][0]).to.include({ password: expectedPassword, }); + chai.expect(db.users.put.args[0][0]).to.include({ password: expectedPassword, password_change_required: true }); }); it('should throw for admin user', async () => { const expectedPassword = 'newpassword'; + sinon.stub(roles, 'hasAllPermissions').returns(false); sinon .stub(passwords, 'generate') .returns(expectedPassword); diff --git a/tests/e2e/default/contacts/delete-assigned-place.wdio-spec.js b/tests/e2e/default/contacts/delete-assigned-place.wdio-spec.js index d6f00e7df47..502f35bc26c 100644 --- a/tests/e2e/default/contacts/delete-assigned-place.wdio-spec.js +++ b/tests/e2e/default/contacts/delete-assigned-place.wdio-spec.js @@ -1,6 +1,5 @@ const utils = require('@utils'); const usersAdminPage = require('@page-objects/default/users/user.wdio.page'); -const adminPage = require('@page-objects/default/admin/admin.wdio.page'); const commonPage = require('@page-objects/default/common/common.wdio.page'); const contactPage = require('@page-objects/default/contacts/contacts.wdio.page'); const loginPage = require('@page-objects/default/login/login.wdio.page'); @@ -17,6 +16,7 @@ describe('User Test Cases -> Creating Users ->', () => { name: 'district_hospital', type: 'district_hospital', }); + const NEW_PASSWORD = 'Pa33word1'; const person = personFactory.build({ parent: { @@ -59,9 +59,17 @@ describe('User Test Cases -> Creating Users ->', () => { password ); await usersAdminPage.saveUser(); - await adminPage.logout(); - await loginPage.login({ username, password }); + + await commonPage.reloadSession(); + await loginPage.setUsernameValue(username); + await loginPage.setPasswordValue(password); + await (await loginPage.loginButton()).click(); + await loginPage.passwordReset(password, NEW_PASSWORD, NEW_PASSWORD); + await (await loginPage.updatePasswordButton()).click(); + await commonPage.waitForPageLoaded(); + await commonPage.goToPeople(); + await contactPage.getAllLHSContactsNames(); await contactPage.selectLHSRowByText(districtHospital2.name); await commonPage.openMoreOptionsMenu(); diff --git a/tests/e2e/default/contacts/person-under-area.wdio-spec.js b/tests/e2e/default/contacts/person-under-area.wdio-spec.js index d34fc0ae78c..7c79d3b16ef 100644 --- a/tests/e2e/default/contacts/person-under-area.wdio-spec.js +++ b/tests/e2e/default/contacts/person-under-area.wdio-spec.js @@ -9,6 +9,7 @@ const personFactory = require('@factories/cht/contacts/person'); describe('Create Person Under Area, ', () => { const username = 'jack_test'; const password = 'Jacktest@123'; + const NEW_PASSWORD = 'Pa33word1'; const places = placeFactory.generateHierarchy(); const districtHospital = places.get('district_hospital'); @@ -38,7 +39,12 @@ describe('Create Person Under Area, ', () => { await usersAdminPage.saveUser(); await commonPage.reloadSession(); - await loginPage.login({ username, password }); + await loginPage.setPasswordValue(password); + await loginPage.setUsernameValue(username); + await (await loginPage.loginButton()).click(); + await loginPage.passwordReset(password, NEW_PASSWORD, NEW_PASSWORD); + await (await loginPage.updatePasswordButton()).click(); + await commonPage.waitForPageLoaded(); await commonPage.goToPeople(); const rows = await contactPage.getAllLHSContactsNames(); diff --git a/tests/e2e/default/login/login-logout.wdio-spec.js b/tests/e2e/default/login/login-logout.wdio-spec.js index e8fdbe0bfdc..a70be9ec05f 100644 --- a/tests/e2e/default/login/login-logout.wdio-spec.js +++ b/tests/e2e/default/login/login-logout.wdio-spec.js @@ -1,10 +1,12 @@ const loginPage = require('@page-objects/default/login/login.wdio.page'); const commonPage = require('@page-objects/default/common/common.wdio.page'); +const userFactory = require('@factories/cht/users/users'); +const placeFactory = require('@factories/cht/contacts/place'); const modalPage = require('@page-objects/default/common/modal.wdio.page'); const constants = require('@constants'); const utils = require('@utils'); -describe('Login page funcionality tests', () => { +describe('Login page functionality tests', () => { const auth = { username: constants.USERNAME, password: constants.PASSWORD @@ -165,4 +167,99 @@ describe('Login page funcionality tests', () => { await (await commonPage.tabsSelector.messagesTab()).waitForDisplayed(); }); }); + + describe('Password Reset', () => { + const CURRENT_PASSWORD_INCORRECT = 'Current password is not correct'; + const MISSING_ALL_FIELDS = 'Missing required fields: "Current password, New password, Confirm password"'; + const MISSING_PASSWORD_CONFIRM = 'Missing required fields: "Confirm password"'; + const PASSWORD_WEAK = 'The password is too easy to guess. Include a range of characters to make it more complex.'; + const PASSWORD_MISMATCH = 'Password and confirm password must match'; + const PASSWORD_SAME = 'New password must be different from current password'; + const NEW_PASSWORD = 'Pa33word1'; + const places = placeFactory.generateHierarchy(); + const districtHospital = places.get('district_hospital'); + const user = userFactory.build({ place: districtHospital._id, roles: ['chw'] }); + + before(async () => { + await utils.saveDocs([...places.values()]); + await utils.createUsers([user], false, true); + }); + + after(async () => { + await utils.deleteUsers([user]); + }); + + it('should verify all fields are missing', async () => { + await browser.url('/'); + await loginPage.setPasswordValue(user.password); + await loginPage.setUsernameValue(user.username); + await (await loginPage.loginButton()).click(); + await loginPage.passwordReset('', '', ''); + await (await loginPage.updatePasswordButton()).click(); + expect(await loginPage.getPasswordResetErrorMessage('fields-required')).to.equal(MISSING_ALL_FIELDS); + }); + + it('should verify confirm password is missing', async () => { + await browser.url('/'); + await loginPage.setPasswordValue(user.password); + await loginPage.setUsernameValue(user.username); + await (await loginPage.loginButton()).click(); + await loginPage.passwordReset(user.password, user.password, ''); + await (await loginPage.updatePasswordButton()).click(); + expect(await loginPage.getPasswordResetErrorMessage('fields-required')).to.equal(MISSING_PASSWORD_CONFIRM); + }); + + it('should verify password strength', async () => { + await browser.url('/'); + await loginPage.setPasswordValue(user.password); + await loginPage.setUsernameValue(user.username); + await (await loginPage.loginButton()).click(); + await loginPage.passwordReset(user.password, '12345678', '12345678'); + await (await loginPage.updatePasswordButton()).click(); + expect(await loginPage.getPasswordResetErrorMessage('password-weak')).to.equal(PASSWORD_WEAK); + }); + + it('should verify current password is not correct', async () => { + await browser.url('/'); + await loginPage.setPasswordValue(user.password); + await loginPage.setUsernameValue(user.username); + await (await loginPage.loginButton()).click(); + await loginPage.passwordReset('12', user.password, user.password); + await (await loginPage.updatePasswordButton()).click(); + expect(await loginPage.getPasswordResetErrorMessage('current-password-incorrect')).to.equal( + CURRENT_PASSWORD_INCORRECT + ); + }); + + it('should verify current password cannot be same as new password', async () => { + await browser.url('/'); + await loginPage.setPasswordValue(user.password); + await loginPage.setUsernameValue(user.username); + await (await loginPage.loginButton()).click(); + await loginPage.passwordReset(user.password, user.password, user.password); + await (await loginPage.updatePasswordButton()).click(); + expect(await loginPage.getPasswordResetErrorMessage('password-same')).to.equal(PASSWORD_SAME); + }); + + it('should verify password and confirm password mismatch', async () => { + await browser.url('/'); + await loginPage.setPasswordValue(user.password); + await loginPage.setUsernameValue(user.username); + await (await loginPage.loginButton()).click(); + await loginPage.passwordReset(user.password, NEW_PASSWORD, 'pass'); + await (await loginPage.updatePasswordButton()).click(); + expect(await loginPage.getPasswordResetErrorMessage('password-mismatch')).to.equal(PASSWORD_MISMATCH); + }); + + it('should reset password successfully and redirect to webapp', async () => { + await browser.url('/'); + await loginPage.setPasswordValue(user.password); + await loginPage.setUsernameValue(user.username); + await (await loginPage.loginButton()).click(); + await loginPage.passwordReset(user.password, NEW_PASSWORD, NEW_PASSWORD); + await (await loginPage.updatePasswordButton()).click(); + await commonPage.waitForPageLoaded(); + await (await commonPage.tabsSelector.messagesTab()).waitForDisplayed(); + }); + }); }); diff --git a/tests/e2e/default/service-worker/service-worker.wdio-spec.js b/tests/e2e/default/service-worker/service-worker.wdio-spec.js index e3d4e41aa53..4e17ceb4251 100644 --- a/tests/e2e/default/service-worker/service-worker.wdio-spec.js +++ b/tests/e2e/default/service-worker/service-worker.wdio-spec.js @@ -128,15 +128,18 @@ describe('Service worker cache', () => { '/img/icon.png', '/img/icon-back.svg', '/img/layers.png', + '/login/auth-utils.js', '/login/images/hide-password.svg', '/login/images/show-password.svg', '/login/lib-bowser.js', + '/login/password-reset.js', '/login/script.js', '/login/style.css', '/main.js', '/manifest.json', '/medic/_design/medic/_rewrite/', '/medic/login', + '/medic/password-reset', '/polyfills.js', '/runtime.js', '/scripts.js', diff --git a/tests/e2e/default/transitions/create-user-for-contacts.replace-user.wdio-spec.js b/tests/e2e/default/transitions/create-user-for-contacts.replace-user.wdio-spec.js index a67c72d1c3d..8bbad1c9f3a 100644 --- a/tests/e2e/default/transitions/create-user-for-contacts.replace-user.wdio-spec.js +++ b/tests/e2e/default/transitions/create-user-for-contacts.replace-user.wdio-spec.js @@ -18,6 +18,7 @@ describe('Create user for contacts', () => { const REPLACE_USER_FORM_ID = 'replace_user'; const OTHER_REPLACE_FORM_ID = 'other_replace_form'; const DISABLED_USER_PASSWORD = 'n3wPassword!'; + const NEW_PASSWORD = 'Pa33word1'; const USER_CONTACT = utils.deepFreeze(personFactory.build({ role: 'chw' })); @@ -250,9 +251,15 @@ describe('Create user for contacts', () => { // Can still login as the original user (with the manually updated password) await commonPage.logout(); - await loginPage.login({ ...ORIGINAL_USER, password: DISABLED_USER_PASSWORD }); + await browser.url('/'); + await loginPage.setUsernameValue(ORIGINAL_USER.username); + await loginPage.setPasswordValue(DISABLED_USER_PASSWORD); + await (await loginPage.loginButton()).click(); + await loginPage.passwordReset(DISABLED_USER_PASSWORD, NEW_PASSWORD, NEW_PASSWORD); + await (await loginPage.updatePasswordButton()).click(); await commonPage.waitForPageLoaded(); await commonPage.sync(); + await sentinelUtils.waitForSentinel(); await commonPage.goToReports(); const basicReportId3 = await createUserForContactsPage.submitBasicForm(); const basicReport3 = await utils.getDoc(basicReportId3); diff --git a/tests/e2e/default/users/create-meta-db.wdio-spec.js b/tests/e2e/default/users/create-meta-db.wdio-spec.js index da553004b19..08bcb4d99b5 100644 --- a/tests/e2e/default/users/create-meta-db.wdio-spec.js +++ b/tests/e2e/default/users/create-meta-db.wdio-spec.js @@ -9,9 +9,10 @@ describe('Create user meta db : ', () => { const USERNAME = 'fulltester'; const FULL_NAME = 'Roger Milla'; const PASSWORD = 'StrongP@ssword1'; + const NEW_PASSWORD = 'Pa33word1'; const OPTIONS = { - auth: { username: USERNAME, password: PASSWORD }, + auth: { username: USERNAME, password: NEW_PASSWORD }, method: 'GET', userName: USERNAME }; @@ -26,7 +27,12 @@ describe('Create user meta db : ', () => { await commonPage.goToMessages(); await commonPage.logout(); - await loginPage.login({ username: USERNAME, password: PASSWORD }); + await browser.url('/'); + await loginPage.setPasswordValue(PASSWORD); + await loginPage.setUsernameValue(USERNAME); + await (await loginPage.loginButton()).click(); + await loginPage.passwordReset(PASSWORD, NEW_PASSWORD, NEW_PASSWORD); + await (await loginPage.updatePasswordButton()).click(); await commonPage.waitForPageLoaded(); await commonPage.goToReports(); diff --git a/tests/integration/api/controllers/login.spec.js b/tests/integration/api/controllers/login.spec.js index c14a19c86be..a2037c3712e 100644 --- a/tests/integration/api/controllers/login.spec.js +++ b/tests/integration/api/controllers/login.spec.js @@ -50,6 +50,13 @@ const expectLoginToWork = (response) => { chai.expect(response.body).to.equal('/'); }; +const expectRedirectToPasswordReset = (response) => { + chai.expect(response).to.include({ status: 302 }); + chai.expect(response.headers.getSetCookie()).to.be.an('array'); + chai.expect(response.headers.getSetCookie().find(cookie => cookie.startsWith('userCtx'))).to.be.ok; + chai.expect(response.body).to.equal('/medic/password-reset'); +}; + const expectLoginToFail = (response) => { chai.expect(response.headers.getSetCookie()).to.deep.equal([]); chai.expect(response.status).to.equal(401); @@ -117,7 +124,7 @@ describe('login', () => { .then(response => expectLoginToFail(response)); }); - it('should succeed with right credentials', () => { + it('should succeed with right credentials without redirecting to password-reset', () => { const opts = { path: '/api/v1/users', method: 'POST', @@ -125,9 +132,31 @@ describe('login', () => { }; return utils .request(opts) + .then(() => getUser(user)) + .then(userDoc => { + // Overriding password_change_required for new user + userDoc.password_change_required = false; + return utils.request({ + path: `/_users/${userDoc._id}`, + method: 'PUT', + body: userDoc + }); + }) .then(() => loginWithData({ user: user.username, password })) .then(response => expectLoginToWork(response)); }); + + it('should succeed with right credentials and redirect to password-reset for new users', () => { + const opts = { + path: '/api/v1/users', + method: 'POST', + body: user + }; + return utils + .request(opts) + .then(() => loginWithData({ user: user.username, password })) + .then(response => expectRedirectToPasswordReset(response)); + }); }); describe('token login', () => { diff --git a/tests/integration/api/controllers/users.spec.js b/tests/integration/api/controllers/users.spec.js index a129526f3ab..73eb78dde96 100644 --- a/tests/integration/api/controllers/users.spec.js +++ b/tests/integration/api/controllers/users.spec.js @@ -351,7 +351,6 @@ describe('Users API', () => { const userDoc = await utils.usersDb.get(getUserId(username)); chai.expect(userDoc.contact_id).to.equal(newContactId); }); - }); describe('/api/v1/users-info', () => { @@ -608,6 +607,7 @@ describe('Users API', () => { user = { username: 'testuser', password, + password_change_required: false, roles: ['district_admin'], place: { _id: 'fixture:test', @@ -966,6 +966,7 @@ describe('Users API', () => { { username: 'offline2', password: password, + password_change_required: false, place: { _id: 'fixture:offline2', type: 'health_center', @@ -981,6 +982,7 @@ describe('Users API', () => { { username: 'online2', password: password, + password_change_required: false, place: { _id: 'fixture:online2', type: 'health_center', @@ -996,6 +998,7 @@ describe('Users API', () => { { username: 'offlineonline2', password: password, + password_change_required: false, place: { _id: 'fixture:offlineonline2', type: 'health_center', @@ -1510,7 +1513,7 @@ describe('Users API', () => { .then(() => getUser(user)) .then(user => firstTokenLogin = user.token_login) .then(() => { - const updates = { token_login: false, password }; + const updates = { token_login: false, password, password_change_required: false }; return utils.request({ path: `/api/v1/users/${user.username}`, method: 'POST', body: updates }); }) .then(response => { @@ -2057,4 +2060,57 @@ describe('Users API', () => { } }); }); + + describe('POST /api/v1/users', () => { + let places; + let contact; + + before(async () => { + const placeAttributes = { + parent: { _id: parentPlace._id }, + type: 'health_center', + }; + places = [ + placeFactory.place().build({ ...placeAttributes, name: 'place1' }), + ]; + contact = personFactory.build({ + parent: { _id: places[0]._id, parent: places[0].parent }, + }); + await utils.saveDocs([...places, contact]); + }); + + afterEach(async () => { + await utils.revertSettings(true); + }); + + const createUserRequest = async (roles = ['chw'], permissions = []) => { + await utils.updatePermissions(roles, permissions, [], { ignoreReload: true }); + + const userPayload = { + username: uuid(), + password: password, + place: places[0]._id, + contact: contact._id, + roles: roles + }; + + await utils.request({ + path: '/api/v1/users', + method: 'POST', + body: userPayload + }); + + return utils.usersDb.get(getUserId(userPayload.username)); + }; + + it('should not set password_change_required when user has can_skip_password_change permission', async () => { + const userDoc = await createUserRequest(['chw'], ['can_skip_password_change']); + expect(userDoc.password_change_required).to.equal(false); + }); + + it('should set password_change_required when user does not have can_skip_password_change permission', async () => { + const userDoc = await createUserRequest(['chw'], ['can_edit']); + expect(userDoc.password_change_required).to.equal(true); + }); + }); }); diff --git a/tests/page-objects/default/login/login.wdio.page.js b/tests/page-objects/default/login/login.wdio.page.js index a965e0d21d4..d958063d24a 100644 --- a/tests/page-objects/default/login/login.wdio.page.js +++ b/tests/page-objects/default/login/login.wdio.page.js @@ -3,6 +3,10 @@ const utils = require('@utils'); const commonPage = require('@page-objects/default/common/common.wdio.page'); const loginButton = () => $('#login'); +const updatePasswordButton = () => $('#update-password'); +const resetPasswordField = () => $('#form[action="/medic/password-reset"] #password'); +const confirmPasswordField = () => $('#confirm-password'); +const currentPasswordField = () => $('#current-password'); const userField = () => $('#user'); const passwordField = () => $('#password'); const passwordToggleButton = () => $('#password-toggle'); @@ -11,6 +15,7 @@ const labelForPassword = () => $('label[for="password"]'); const errorMessageField = () => $('p.error.incorrect'); const localeByName = (locale) => $(`.locale[name="${locale}"]`); const tokenLoginError = (reason) => $(`.error.${reason}`); +const passwordResetMessageField = (errorMsg) => $(`p.error.${errorMsg}`); const privacyPolicyPageLink = () => $('a[translate="privacy.policy"]'); const getErrorMessage = async () => { @@ -18,12 +23,17 @@ const getErrorMessage = async () => { return await (await errorMessageField()).getText(); }; +const getPasswordResetErrorMessage = async (errorMsg) => { + await (await passwordResetMessageField(errorMsg)).waitForDisplayed(); + return await (await passwordResetMessageField(errorMsg)).getText(); +}; + const login = async ({ username, password, createUser = false, locale, loadPage = true, privacyPolicy, adminApp }) => { if (utils.isMinimumChromeVersion) { await browser.url('/'); } await setPasswordValue(password); - await (await userField()).setValue(username); + await setUsernameValue(username); await changeLocale(locale); await (await loginButton()).click(); @@ -99,6 +109,9 @@ const changeLocale = async locale => { }; const changeLanguage = async (languageCode, userTranslation) => { + if (utils.isMinimumChromeVersion) { + await browser.url('/'); + } await changeLocale(languageCode); await browser.waitUntil(async () => await (await labelForUser()).getText() === userTranslation); return { @@ -134,6 +147,29 @@ const setPasswordValue = async (password) => { await (await passwordField()).setValue(password); }; +const setConfirmPasswordValue = async (confirmPassword) => { + await (await confirmPasswordField()).waitForDisplayed(); + await (await confirmPasswordField()).setValue(confirmPassword); +}; + +const setCurrentPasswordValue = async (currentPassword) => { + await (await currentPasswordField()).waitForDisplayed(); + await (await currentPasswordField()).setValue(currentPassword); +}; + +const setUsernameValue = async (username) => { + await (await userField()).waitForDisplayed(); + await (await userField()).setValue(username); +}; + +const passwordReset = async (currentPassword, password, confirmPassword) => { + await setCurrentPasswordValue(currentPassword); + await (await resetPasswordField()).waitForDisplayed(); + await (await resetPasswordField()).setValue(password); + await setConfirmPasswordValue(confirmPassword); + await (await updatePasswordButton()).click(); +}; + const goToPrivacyPolicyPage = async () => { await (await privacyPolicyPageLink()).click(); }; @@ -152,6 +188,12 @@ module.exports = { getErrorMessage, togglePassword, setPasswordValue, + setUsernameValue, + setConfirmPasswordValue, + setCurrentPasswordValue, + passwordReset, + updatePasswordButton, + getPasswordResetErrorMessage, privacyPolicyPageLink, goToPrivacyPolicyPage }; diff --git a/tests/utils/index.js b/tests/utils/index.js index 55ce3e6ae1d..4ca9b204199 100644 --- a/tests/utils/index.js +++ b/tests/utils/index.js @@ -844,15 +844,19 @@ const getCreatedUsers = async () => { * Creates users - optionally also creating their meta dbs * @param {Array} users - list of users to be created * @param {Boolean} meta - if true, creates meta db-s as well, default false + * @param {Boolean} password_change_required - if true, will require user to reset password on first time login * @return {Promise} * */ -const createUsers = async (users, meta = false) => { +const createUsers = async (users, meta = false, password_change_required = false) => { const createUserOpts = { path: '/api/v1/users', method: 'POST' }; const createUserV3Opts = { path: '/api/v3/users', method: 'POST' }; for (const user of users) { const options = { - body: user, + body: { + ...user, + password_change_required: password_change_required ? undefined : false + }, ...(Array.isArray(user.place) ? createUserV3Opts : createUserOpts) }; await request(options); diff --git a/webapp/src/js/bootstrapper/index.js b/webapp/src/js/bootstrapper/index.js index f0136478a51..03d6d509749 100644 --- a/webapp/src/js/bootstrapper/index.js +++ b/webapp/src/js/bootstrapper/index.js @@ -89,10 +89,16 @@ const dbInfo = getDbInfo(); const userCtx = getUserCtx(); const hasForceLoginCookie = document.cookie.includes('login=force'); + const passwordStatus = localStorage.getItem('passwordStatus'); if (!userCtx || hasForceLoginCookie) { return redirectToLogin(dbInfo); } + if (passwordStatus === 'PASSWORD_CHANGED') { + setUiStatus('PASSWORD_CHANGE_SUCCESS'); + localStorage.removeItem('passwordStatus'); + } + if (hasFullDataAccess(userCtx)) { return Promise.resolve(); } diff --git a/webapp/src/js/bootstrapper/translator.js b/webapp/src/js/bootstrapper/translator.js index d658d054344..c9f46008ee4 100644 --- a/webapp/src/js/bootstrapper/translator.js +++ b/webapp/src/js/bootstrapper/translator.js @@ -7,6 +7,7 @@ const TRANSLATIONS = { en: { FETCH_INFO: ({ count, total }) => `Fetching info (${count} of ${total} docs )…`, LOAD_APP: 'Loading app…', + PASSWORD_CHANGE_SUCCESS: 'Password changed successfully', PURGE_INIT: 'Checking data…', PURGE_INFO: ({ count }) => `Cleaned ${count} documents…`, PURGE_META: 'Cleaning metadata…', @@ -25,6 +26,7 @@ const TRANSLATIONS = { es: { FETCH_INFO: ({ count, total }) => `Obteniendo información (${count} de ${total} docs)…`, LOAD_APP: 'Cargando aplicación…', + PASSWORD_CHANGE_SUCCESS: 'Cambio de contraseña exitoso', PURGE_INIT: 'Verificación de datos…', PURGE_INFO: ({ count }) => `Limpiado ${count} documentos…`, PURGE_META: 'Limpieza de metadatos…', @@ -43,6 +45,7 @@ const TRANSLATIONS = { sw: { FETCH_INFO: ({ count, total }) => `Inachukua habari (${count} of ${total})…`, LOAD_APP: 'Inapakia programu…', + PASSWORD_CHANGE_SUCCESS: 'Umefaulu kubadilisha nenosiri', PURGE_INIT: 'Kuangalia takwimu…', PURGE_INFO: ({ count }) => `Imesafisha hati ${count}…`, PURGE_META: 'inasafisha metadata…', @@ -61,6 +64,7 @@ const TRANSLATIONS = { ne: { FETCH_INFO: ({ count, total }) => eurodigit.to_non_euro.devanagari(`${total} मध्ये ${count} डकुमेन्ट लोड हुँदै …`), LOAD_APP: 'एप लोड गर्दै…', + PASSWORD_CHANGE_SUCCESS: 'पासवर्ड सफलतापूर्वक परिवर्तन भयो', PURGE_INIT: 'डाटा जाँच गर्दै…', PURGE_INFO: ({ count }) => eurodigit.to_non_euro.devanagari(`${count} वटा डकुमेन्ट सफा गरीयो…`), PURGE_META: 'मेटा डाटा सफा गर्दै…', @@ -79,6 +83,7 @@ const TRANSLATIONS = { fr: { FETCH_INFO: ({ count, total }) => `Récupération des données (${count} sur ${total} documents)…`, LOAD_APP: 'Chargement de l’application…', + PASSWORD_CHANGE_SUCCESS: 'Mot de passe modifié avec succès', PURGE_INIT: 'Vérification des données…', PURGE_INFO: ({ count }) => `${count} document(s) nettoyé(s)…`, PURGE_META: 'Cleaning meta data…', @@ -97,6 +102,7 @@ const TRANSLATIONS = { hi: { FETCH_INFO: ({ count, total }) => `डॉक्युमेंट लोड हो रहें हैं (${total} मेंस से ${count})…`, LOAD_APP: 'एप्लीकेशन लोड हो रही है…', + PASSWORD_CHANGE_SUCCESS: 'पासवर्ड सफलतापूर्वक बदला गया', PURGE_INIT: 'डेटा की जाँच…', PURGE_INFO: ({ count }) => `${count} दस्तावेज साफ किए…`, PURGE_META: 'Nettoyage des métadonnées…', @@ -115,6 +121,7 @@ const TRANSLATIONS = { id: { FETCH_INFO: ({ count, total }) => `Mengambil informasi (${count} dari ${total} dokumen)…`, LOAD_APP: 'Memuat aplikasi…', + PASSWORD_CHANGE_SUCCESS: 'Perubahan kata sandi berhasil', PURGE_INIT: 'Mengecek data…', PURGE_INFO: ({ count }) => `Menghapus ${count} dokument…`, PURGE_META: 'Menghapus metadata…', @@ -133,6 +140,7 @@ const TRANSLATIONS = { ar: { FETCH_INFO: ({ count, total }) => `جارٍ جلب المعلومات (${count} من مستندات ${total})...`, LOAD_APP: 'جارٍ تحميل التطبيق…', + PASSWORD_CHANGE_SUCCESS: 'تم تغيير كلمة المرور بنجاح', PURGE_INIT: 'جارٍ التحقق من البيانات…', PURGE_INFO: ({ count }) => `تم تنظيف ${count} من المستندات...`, PURGE_META: 'جارٍ تنظيف البيانات التعريفية…', diff --git a/webapp/tests/mocha/unit/bootstrapper.spec.js b/webapp/tests/mocha/unit/bootstrapper.spec.js index d3cf5580b54..645e562e22b 100644 --- a/webapp/tests/mocha/unit/bootstrapper.spec.js +++ b/webapp/tests/mocha/unit/bootstrapper.spec.js @@ -99,6 +99,12 @@ describe('bootstrapper', () => { return promise; }); + global.localStorage = { + getItem: sinon.stub(), + setItem: sinon.stub(), + removeItem: sinon.stub(), + }; + $ = sinon.stub().returns({ text: sinon.stub(), click: sinon.stub(), @@ -209,6 +215,19 @@ describe('bootstrapper', () => { expect(initialReplication.replicate.args).to.deep.equal([[ remoteMedicDb, localMedicDb ]]); }); + it('should handle password status change and localStorage cleanup', async () => { + setUserCtxCookie({ name: 'jim' }); + sinon.stub(initialReplication, 'isReplicationNeeded').resolves(false); + sinon.stub(utils, 'setOptions'); + sinon.stub(purger, 'purgeMeta').returns({ on: purgeOn }); + + localStorage.getItem.withArgs('passwordStatus').returns('PASSWORD_CHANGED'); + + await bootstrapper(pouchDbOptions); + + expect(localStorage.removeItem.calledWith('passwordStatus')).to.be.true; + }); + it('should redirect to login when no userCtx cookie found', async () => { sinon.stub(utils, 'setOptions');