Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(#9547): add password change feature on first time login #9581

Open
wants to merge 61 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
d7173a6
chore: test password change routing
Benmuiruri Oct 24, 2024
523fc30
Add password reset flag to API
Benmuiruri Oct 29, 2024
1ef9996
chore: add password validation
Benmuiruri Oct 30, 2024
efe3a72
chore: add password validation error msgs
Benmuiruri Oct 30, 2024
057782c
chore: prevent unauthorized access before password change
Benmuiruri Oct 30, 2024
63017ca
chore: redirect back to login if not password reset
Benmuiruri Oct 30, 2024
bfa9485
chore: use cookie to prevent access
Benmuiruri Oct 31, 2024
c9b570d
chore: try cookie to show UI
Benmuiruri Oct 31, 2024
f1dea9a
chore: show password success UI
Benmuiruri Oct 31, 2024
7e15098
sonar reduce cognitive
Benmuiruri Oct 31, 2024
d2d18e9
chore: sonar fixes
Benmuiruri Nov 1, 2024
9d1c055
Merge branch 'master' of github.com:medic/cht-core into 9547-change-p…
Benmuiruri Nov 4, 2024
8fee9e8
chore: interpolate translations
Benmuiruri Nov 4, 2024
0323df5
chore: first round of feedback
Benmuiruri Nov 4, 2024
935c721
chore: additional feedback
Benmuiruri Nov 4, 2024
f4f4de0
chore: refactor password validation
Benmuiruri Nov 6, 2024
04b3f97
chore: refactor toggle password and legacy code
Benmuiruri Nov 6, 2024
1a4e971
chore: remove passwordUpdate cookie and set minimal cookie without Auth
Benmuiruri Nov 6, 2024
f6d9949
chore: refactor setting basic Cookie
Benmuiruri Nov 7, 2024
d693b75
chore: clean password reset
Benmuiruri Nov 7, 2024
01acf08
chore: fix userService unit test
Benmuiruri Nov 7, 2024
a4ea9a6
chore: add fr translations
Benmuiruri Nov 7, 2024
13092d1
Merge branch 'master' of github.com:medic/cht-core into 9547-change-p…
Benmuiruri Nov 8, 2024
ecc3f82
chore: refactor to initial flow
Benmuiruri Nov 8, 2024
20cde88
chore: simplify check
Benmuiruri Nov 8, 2024
74ed3e2
chore: add ne translations
Benmuiruri Nov 9, 2024
fcf73bd
chore: use getUserDoc not getUserCtx
Benmuiruri Nov 9, 2024
b3ec3f0
chore: refactor password reset, add password reset unit test
Benmuiruri Nov 10, 2024
c15cced
chore: refactor password validation
Benmuiruri Nov 11, 2024
3c0f2fa
chore: set password_change_required on password update
Benmuiruri Nov 11, 2024
5fcb64d
chore: update e2e to do password reset
Benmuiruri Nov 11, 2024
1f223f8
Add password reset e2e
Benmuiruri Nov 12, 2024
5a24839
disable eslint to run tests in ci
Benmuiruri Nov 12, 2024
ff6192c
try api eslint
Benmuiruri Nov 12, 2024
4817bb1
chore: add unit tests
Benmuiruri Nov 12, 2024
47d990b
chore: skip password reset for admin
Benmuiruri Nov 12, 2024
0a814c6
chore: self review
Benmuiruri Nov 12, 2024
2bc1603
chore: update service worker unit test
Benmuiruri Nov 12, 2024
3e6fd03
chore: update integration tests and fix login e2e
Benmuiruri Nov 13, 2024
dd73991
chore: sonar
Benmuiruri Nov 13, 2024
b48c967
Merge branch 'master' of github.com:medic/cht-core into 9547-change-p…
Benmuiruri Nov 13, 2024
f649876
chore: add selector specificity
Benmuiruri Nov 13, 2024
8511c67
address feedback
Benmuiruri Nov 14, 2024
cbde20f
validate user changing password
Benmuiruri Nov 15, 2024
71ccda5
chore: try iife pattern
Benmuiruri Nov 15, 2024
cf77f9f
chore: sonar
Benmuiruri Nov 15, 2024
6c7d976
validate password reset in auth middleware
Benmuiruri Nov 15, 2024
e0806e0
update e2e
Benmuiruri Nov 18, 2024
95d5f9c
Merge branch 'master' of github.com:medic/cht-core into 9547-change-p…
Benmuiruri Nov 18, 2024
40bee0a
chore: update breaking e2e
Benmuiruri Nov 18, 2024
87170ff
chore: sonar
Benmuiruri Nov 18, 2024
c23d3e2
chore: update e2e tests
Benmuiruri Nov 18, 2024
4312722
chore: lint
Benmuiruri Nov 19, 2024
424fe87
Merge branch 'master' of github.com:medic/cht-core into 9547-change-p…
Benmuiruri Nov 28, 2024
fc9e645
chore: sonar
Benmuiruri Nov 28, 2024
874fafc
update e2e and fix sonar
Benmuiruri Nov 28, 2024
6d72df4
chore: sonar
Benmuiruri Nov 28, 2024
d68e8e9
refactor auth::checkPasswordChange
Benmuiruri Dec 2, 2024
b0ef429
chore: update integration tests
Benmuiruri Dec 2, 2024
3a5fd7c
Merge branch 'master' of github.com:medic/cht-core into 9547-change-p…
Benmuiruri Dec 2, 2024
f3690ad
chore: update sentinel integration tests
Benmuiruri Dec 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/.eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
"files": [
"src/public/**/*.js"
],
"parserOptions": {
"sourceType": "module"
},
"rules": {
"no-console": "off",
"compat/compat": "error"
Expand Down
6 changes: 6 additions & 0 deletions api/resources/translations/messages-en.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -966,6 +971,7 @@ partner.supporting = Supporting partners
partner.tab.partners = Partners
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.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.
Expand Down
6 changes: 6 additions & 0 deletions api/resources/translations/messages-es.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -966,6 +971,7 @@ partner.supporting = Socios que está apoyando
partner.tab.partners = Socios
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.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.
Expand Down
6 changes: 6 additions & 0 deletions api/resources/translations/messages-fr.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -966,6 +971,7 @@ partner.supporting = Partenaires de soutien
partner.tab.partners = Partenaires
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.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é.
Expand Down
1 change: 1 addition & 0 deletions api/resources/translations/messages-id.properties
Original file line number Diff line number Diff line change
Expand Up @@ -884,6 +884,7 @@ partner.supporting =
partner.tab.partners =
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.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
Expand Down
6 changes: 6 additions & 0 deletions api/resources/translations/messages-ne.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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 = बच्चाको जन्म तौल
Expand Down Expand Up @@ -966,6 +971,7 @@ partner.supporting = सहयोगी पार्टनरहरू
partner.tab.partners = पार्टनरहरू
password.incorrect = पासवर्ड मिलेन।
password.length.minimum = पासवर्ड कम्तीमा {{minimum}} अक्षरको हुनुपर्छ।
password.must.match = तपाईंले पासवर्ड हाल्नुहोस् र पासवर्ड पुष्टि गर्नुहोस् नामक फिल्डमा हाल्नुभएको पासवर्ड एउटै छैन। फेरि प्रयास गर्नुहोस्।
password.update = अपडेट पासवर्ड
password.weak = यो पासवर्ड कमजोर छ।
patient\ id\ not\ found\ response = बिरामिको आईडी नपाइएमा पठाइने सन्देश
Expand Down
6 changes: 6 additions & 0 deletions api/resources/translations/messages-sw.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -966,6 +971,7 @@ partner.supporting = Washirika wanaounga mkono
partner.tab.partners = Washirika
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.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
Expand Down
218 changes: 181 additions & 37 deletions api/src/controllers/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ const translations = require('../translations');
const template = require('../services/template');
const rateLimitService = require('../services/rate-limit');
const serverUtils = require('../server-utils');
const { validatePassword } = require('@medic/user-management/src/users');
Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved

const PASSWORD_RESET_URL = '/medic/password-reset';

const ERROR_KEY_MAPPING = {
'password.must.match': 'password-mismatch', //NoSONAR
'password.weak': 'password-weak', //NoSONAR
'password.length.minimum': 'password-short' //NoSONAR
};

const templates = {
login: {
Expand Down Expand Up @@ -51,6 +60,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'
],
}
};

const skipPasswordChange = async (userCtx) => {
if (roles.isDbAdmin(userCtx)) {
return true;
}
return await auth.hasAllPermissions(userCtx, 'can_skip_password_change');
};

const getHomeUrl = userCtx => {
Expand Down Expand Up @@ -190,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 (user?.password_change_required && !user?.token_login?.active && !await skipPasswordChange(userCtx)) {
Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved
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);
Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved
};

const renderTokenLogin = (req, res) => {
Expand Down Expand Up @@ -300,26 +341,85 @@ const renderLogin = (req) => {
return render('login', req);
};

const renderPasswordReset = (req) => {
return render('passwordReset', req);
};

const validatePasswordReset = (password, confirmPassword) => {
const error = validatePassword(password, confirmPassword);

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 });
}
logger.error('Error logging in: %o', e);
res.status(500).json({ error: 'Unexpected error logging in' });
return sendLoginErrorResponse(e, res);
}
};

const updatePassword = async (user, newPassword) => {
const updatedUser = {
...user,
password: newPassword,
password_change_required: false
};
await db.users.put(updatedUser);
// creating new session immediately after changing a password might 401
await new Promise(resolve => setTimeout(resolve, 50));
Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved
return updatedUser;
};

const createNewSession = async (username, password) => {
const sessionRes = await createSessionRetry({
body: {
user: username,
password: password,
}
});

const sessionCookie = getSessionCookie(sessionRes);
const userCtx = await getUserCtxRetry({ headers: { Cookie: sessionCookie }});

return {
sessionCookie,
userCtx
};
};

module.exports = {
renderLogin,
renderPasswordReset,

get: (req, res, next) => {
return renderLogin(req)
Expand All @@ -328,6 +428,7 @@ module.exports = {
'Link',
'</login/style.css>; rel=preload; as=style, '
+ '</login/script.js>; rel=preload; as=script, '
+ '</login/auth-utils.js>; rel=preload; as=script, '
+ '</login/lib-bowser.js>; rel=preload; as=script'
);
res.send(body);
Expand Down Expand Up @@ -355,6 +456,49 @@ module.exports = {
});
},

getPasswordReset: (req, res, next) => {
return renderPasswordReset(req)
.then(body => {
res.setHeader(
'Link',
'</login/style.css>; rel=preload; as=style, '
+ '</login/auth-utils.js>; rel=preload; as=script, '
+ '</login/password-reset.js>; 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 validation = validatePasswordReset(req.body.password, req.body.confirmPassword);
if (!validation.isValid) {
return res.status(400).json({
error: validation.error,
params: validation.params,
});
}
const user = await db.users.get(`org.couchdb.user:${req.body.user}`);
await updatePassword(user, req.body.password);
Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved

const { sessionCookie, userCtx } = await createNewSession(
user.name,
req.body.password
);

const redirectUrl = await redirectToApp({ req, res, sessionCookie, userCtx });

return res.status(302).send(redirectUrl);
} catch (err) {
logger.error('Error updating password: %o', err);
res.status(500).json({ error: 'Error updating password' });
}
},
tokenGet: (req, res, next) => renderTokenLogin(req, res).catch(next),
tokenPost: async (req, res, next) => {
const limited = await rateLimitService.isLimited(req);
Expand Down
Loading
Loading