diff --git a/api/resources/translations/messages-en.properties b/api/resources/translations/messages-en.properties
index 3bc93546cdc..04456b4157c 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
@@ -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.
diff --git a/api/resources/translations/messages-es.properties b/api/resources/translations/messages-es.properties
index 4dcc0aa8506..f9e64bc7023 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.
diff --git a/api/resources/translations/messages-fr.properties b/api/resources/translations/messages-fr.properties
index 0b9923ec74a..6b458ebb6c1 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é.
diff --git a/api/resources/translations/messages-id.properties b/api/resources/translations/messages-id.properties
index 28e9a589877..2fac8ec0f35 100644
--- a/api/resources/translations/messages-id.properties
+++ b/api/resources/translations/messages-id.properties
@@ -388,6 +388,11 @@ bulkdelete.confirm.title = Hapus pencatatan?
bulkdelete.confirm.title.plural = Hapus pencatatan yang dipilih?
call = Telepon
case_id =
+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
@@ -881,9 +886,12 @@ partner.logo.field =
partner.logo.upload =
partner.name.field =
partner.supporting =
-partner.tab.partners =
+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..3cb75f9e1e7 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 = बिरामिको आईडी नपाइएमा पठाइने सन्देश
diff --git a/api/resources/translations/messages-sw.properties b/api/resources/translations/messages-sw.properties
index 13c4eadb09e..24162f8cbe0 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
diff --git a/api/src/auth.js b/api/src/auth.js
index 508ffc81906..99e7335a6e8 100644
--- a/api/src/auth.js
+++ b/api/src/auth.js
@@ -76,6 +76,20 @@ module.exports = {
});
},
+ checkPasswordChange: async (req) => {
+ if (roles.isDbAdmin(req.userCtx)) {
+ return;
+ }
+
+ const user = await users.getUserDoc(req.userCtx.name);
+ if (user.password_change_required && !user.token_login) {
+ const error = new Error('Password change required');
+ error.code = 403;
+ error.error = 'Password change required';
+ throw error;
+ }
+ },
+
/**
* Extract Basic Auth credentials from a request
*
diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js
index f522bcce7bf..3bddd96f9b2 100644
--- a/api/src/controllers/login.js
+++ b/api/src/controllers/login.js
@@ -9,7 +9,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');
@@ -18,6 +18,17 @@ 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
+};
+
const templates = {
login: {
file: path.join(__dirname, '..', 'templates', 'login', 'index.html'),
@@ -51,6 +62,28 @@ 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'
+ ],
+ }
+};
+
+const skipPasswordChange = (user) => {
+ return !user?.password_change_required;
};
const getHomeUrl = userCtx => {
@@ -190,36 +223,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) => {
@@ -300,26 +343,122 @@ 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.statusCode !== 200) {
+ const error = new Error('Not logged in');
+ error.status = sessionRes.statusCode;
+ 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.statusCode !== 200) {
- res.status(sessionRes.statusCode).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 = async (user, newPassword, retry = 10) => {
+ const updatedUser = {
+ ...user,
+ password: newPassword,
+ password_change_required: false
+ };
+ try {
+ await db.users.put(updatedUser);
+ return updatedUser;
+ } catch (err) {
+ if (retry > 0 && err && err.code === 401) {
+ await new Promise(r => setTimeout(r, 20));
+ return updatePassword(user, newPassword, --retry);
+ }
+ throw err;
+ }
+};
+
+const validateCurrentPassword = async (username, currentPassword, newPassword) => {
+ try {
+ await request.get({
+ url: new URL('/_session', environment.serverUrlNoAuth).toString(),
+ json: true,
+ resolveWithFullResponse: true,
+ auth: { user: username, pass: currentPassword },
+ });
+
+ if (currentPassword === newPassword) {
+ return {
+ isValid: false,
+ error: ERROR_KEY_MAPPING['password.same'],
+ };
}
- logger.error('Error logging in: %o', e);
- res.status(500).json({ error: 'Unexpected error logging in' });
+ return { isValid: true };
+ } catch (err) {
+ if (err.statusCode === 401) {
+ return {
+ isValid: false,
+ error: ERROR_KEY_MAPPING['password.current.incorrect'],
+ };
+ }
+ 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)
@@ -328,6 +467,7 @@ module.exports = {
'Link',
'; rel=preload; as=style, '
+ '; rel=preload; as=script, '
+ + '; rel=preload; as=script, '
+ '; rel=preload; as=script'
);
res.send(body);
@@ -355,6 +495,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.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/middleware/authorization.js b/api/src/middleware/authorization.js
index fe02207beca..beae46ec045 100644
--- a/api/src/middleware/authorization.js
+++ b/api/src/middleware/authorization.js
@@ -40,7 +40,9 @@ module.exports = {
return serverUtils.error('Authentication error', req, res);
}
- next();
+ return auth.checkPasswordChange(req)
+ .then(() => next())
+ .catch(err => serverUtils.error(err, req, res));
},
handleAuthErrorsAllowingAuthorized: (req, res, next) => {
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..e9c31cbd6c1
--- /dev/null
+++ b/api/src/public/login/password-reset.js
@@ -0,0 +1,108 @@
+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 passwordError = document.querySelector('.error.password-short');
+ if (params?.minimum && passwordError) {
+ passwordError.setAttribute('translate-values', JSON.stringify(params));
+ baseTranslate(selectedLocale, translations);
+ }
+};
+
+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 validation = validatePasswordMatch(password, confirmPassword);
+ if (!validation.isValid) {
+ displayPasswordValidationError(JSON.stringify({ error: validation.error }));
+ 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..f47dc543fe6 100644
--- a/api/src/public/login/style.css
+++ b/api/src/public/login/style.css
@@ -101,7 +101,13 @@ 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
{
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 @@
<% } %>
+