From 67b533070d124a274994ce5b3e8a772986a239d1 Mon Sep 17 00:00:00 2001
From: Ben Kiarie <2597305+Benmuiruri@users.noreply.github.com>
Date: Fri, 31 Jan 2025 14:02:25 +0300
Subject: [PATCH] feat(#9547): require password reset on first time login and
admin password update (#9731)
---
admin/src/js/controllers/edit-user.js | 8 +-
admin/src/templates/edit_user.html | 1 +
.../tests/unit/controllers/edit-user.spec.js | 34 +++
.../translations/messages-ar.properties | 8 +
.../translations/messages-en.properties | 13 +-
.../translations/messages-es.properties | 9 +
.../translations/messages-fr.properties | 9 +
.../translations/messages-id.properties | 8 +
.../translations/messages-ne.properties | 9 +
.../translations/messages-sw.properties | 11 +-
api/src/controllers/login.js | 248 +++++++++++++++---
api/src/generate-service-worker.js | 5 +
api/src/public/login/auth-utils.js | 128 +++++++++
api/src/public/login/password-reset.js | 143 ++++++++++
api/src/public/login/script.js | 117 ++-------
api/src/public/login/style.css | 9 +-
api/src/routing.js | 2 +
api/src/services/cookie.js | 4 +
api/src/templates/login/index.html | 1 +
api/src/templates/login/password-reset.html | 57 ++++
api/src/templates/login/token-login.html | 1 +
api/tests/mocha/controllers/login.spec.js | 189 ++++++++++++-
.../mocha/generate-service-worker.spec.js | 3 +
api/tests/mocha/services/cookie.spec.js | 34 +++
config/default/app_settings.json | 1 +
config/demo/app_settings.json | 3 +-
shared-libs/user-management/src/index.js | 3 +-
shared-libs/user-management/src/users.js | 86 +++---
.../user-management/test/unit/users.spec.js | 187 +++++++++++--
.../delete-assigned-place.wdio-spec.js | 14 +-
.../contacts/person-under-area.wdio-spec.js | 8 +-
.../default/login/login-logout.wdio-spec.js | 99 ++++++-
.../service-worker.wdio-spec.js | 3 +
...ser-for-contacts.replace-user.wdio-spec.js | 9 +-
.../default/users/create-meta-db.wdio-spec.js | 10 +-
.../integration/api/controllers/login.spec.js | 31 ++-
.../integration/api/controllers/users.spec.js | 60 ++++-
.../default/login/login.wdio.page.js | 44 +++-
tests/utils/index.js | 8 +-
webapp/src/js/bootstrapper/index.js | 6 +
webapp/src/js/bootstrapper/translator.js | 8 +
webapp/tests/mocha/unit/bootstrapper.spec.js | 19 ++
42 files changed, 1449 insertions(+), 201 deletions(-)
create mode 100644 api/src/public/login/auth-utils.js
create mode 100644 api/src/public/login/password-reset.js
create mode 100644 api/src/templates/login/password-reset.html
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 @@
<% } %>
+