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 52 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
52 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
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
7 changes: 7 additions & 0 deletions api/resources/translations/messages-en.properties
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,12 @@ 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.required = Password and Confirm Password fields are required
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 +972,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 = Passwords 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
7 changes: 7 additions & 0 deletions api/resources/translations/messages-es.properties
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,12 @@ 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.required = Los campos Contraseña y Confirmar contraseña son obligatorios
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 +972,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
1 change: 1 addition & 0 deletions api/resources/translations/messages-fr.properties
Original file line number Diff line number Diff line change
Expand Up @@ -966,6 +966,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 = Les mots de passe et le mot de passe de confirmation 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
7 changes: 7 additions & 0 deletions api/resources/translations/messages-sw.properties
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,12 @@ 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.required = Nenosiri na uthibitisho wa nenosiri zinahitajika
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 +972,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
7 changes: 6 additions & 1 deletion api/src/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,12 @@ module.exports = {
.then(auth => {
if (auth?.userCtx?.name) {
req.headers['X-Medic-User'] = auth.userCtx.name;
return auth.userCtx;
return db.users
.get(`org.couchdb.user:${auth.userCtx.name}`)
.then(user => ({
...auth.userCtx,
password_change_required: !!user.password_change_required
}));
}
throw { code: 500, message: 'Failed to authenticate' };
});
Expand Down
161 changes: 146 additions & 15 deletions api/src/controllers/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ const translations = require('../translations');
const template = require('../services/template');
const rateLimitService = require('../services/rate-limit');
const serverUtils = require('../server-utils');
const passwordTester = require('simple-password-tester');

const { PASSWORD_MINIMUM_LENGTH, PASSWORD_MINIMUM_SCORE } = require('@medic/user-management/src/users');

const templates = {
login: {
Expand Down Expand Up @@ -51,6 +54,30 @@ 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',
Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved
'change.password.hint',
'change.password.submit',
'change.password.new.password',
'change.password.confirm.password',
'change.password.required',
'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 @@ -201,7 +228,9 @@ const setCookies = (req, res, sessionRes) => {
cookie.setSession(res, sessionCookie);
setUserCtxCookie(res, userCtx);
// Delete login=force cookie
res.clearCookie('login');
if (!userCtx.password_change_required) {
cookie.clearCookie(res, 'login');
}

return Promise.resolve()
.then(() => {
Expand All @@ -210,10 +239,12 @@ const setCookies = (req, res, sessionRes) => {
}
})
.then(() => {
const selectedLocale = req.body.locale
|| config.get('locale');
const selectedLocale = req.body.locale || config.get('locale');
cookie.setLocale(res, selectedLocale);
return getRedirectUrl(userCtx, req.body.redirect);
return {
userCtx,
redirectUrl: getRedirectUrl(userCtx, req.body.redirect),
};
});
})
.catch(err => {
Expand Down Expand Up @@ -300,26 +331,71 @@ const renderLogin = (req) => {
return render('login', req);
};

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

const validatePassword = (password, confirmPassword) => {
Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved
if (!password || !confirmPassword) {
return { isValid: false, error: 'required'};
}

if (password.length < PASSWORD_MINIMUM_LENGTH) {
return {
isValid: false,
error: 'short',
params: { minimum: PASSWORD_MINIMUM_LENGTH }
};
}

if (password !== confirmPassword) {
return { isValid: false, error: 'mismatch' };
}

if (passwordTester(password) < PASSWORD_MINIMUM_SCORE) {
return { isValid: false, error: 'weak' };
}

return { isValid: true };
};

const validateSession = async (req) => {
const sessionRes = await createSession(req);
if (sessionRes.statusCode !== 200) {
const error = new Error('Not logged in');
error.status = sessionRes.statusCode;
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' });
};
Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved

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 { userCtx, redirectUrl } = await setCookies(req, res, sessionRes);

const redirectPasswordReset = !await skipPasswordChange(userCtx);
if (redirectPasswordReset){
return res.status(302).send('/medic/password-reset');
}

return 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);
}
};

module.exports = {
renderLogin,
renderPasswordReset,

get: (req, res, next) => {
return renderLogin(req)
Expand All @@ -328,6 +404,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 +432,60 @@ 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 = validatePassword(req.body.password, req.body.confirmPassword);
if (!validation.isValid) {
return res.status(400).json({
error: `password${validation.error}`,
Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved
params: validation.params,
});
}
const userCtx = await auth.getUserCtx(req);
const user = await db.users.get(`org.couchdb.user:${userCtx.name}`);
user.password = req.body.password;
user.password_change_required = false;

await db.users.put(user);

cookie.clearCookie(res, 'login');
cookie.clearCookie(res, 'AuthSession');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This try block can be in the service instead, so the controller is responsible of prepare the response to the front-end. What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which service do you have in mind ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

login service or auth service. Which one do you think fit better?

Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved

req.body = {
...req.body,
user: user.name,
password: req.body.password,
Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved
locale: req.body.locale,
};

const sessionRes = await createSessionRetry(req);
cookie.setPasswordUpdated(res);

const { redirectUrl } = await setCookies(req, res, sessionRes);
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
5 changes: 5 additions & 0 deletions api/src/generate-service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/],
Expand Down
Loading
Loading