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 13 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
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,12 @@ bulkdelete.confirm.title = Delete record?
bulkdelete.confirm.title.plural = Delete selected records?
call = Call
case_id = Case ID
change.password.title = Change your password
Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved
change.password.hint = Use uppercase letters, numbers, and special characters.
change.password.new.password = New Password
Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved
change.password.confirm.password = Confirm password
change.password.submit = Change password
change.password.required = Password and Confirm Password fields are required
child_birth_date = Child birth date
child_birth_outcome = Child birth outcome
child_birth_weight = Child birth weight
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,12 @@ bulkdelete.confirm.title = ¿Eliminar el registro?
bulkdelete.confirm.title.plural = ¿Eliminar registros seleccionados?
call = Llamar
case_id = Identificación del caso
change.password.title = Cambia tu contraseña
Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved
change.password.hint = Utilice letras mayúsculas, números y caracteres especiales..
change.password.new.password = Nueva contraseña
change.password.confirm.password = Confirmar Contraseña
change.password.submit = Cambiar la contraseña
change.password.required = Los campos Contraseña y Confirmar contraseña son obligatorios
Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved
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
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 || false
}));
Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved
}
throw { code: 500, message: 'Failed to authenticate' };
});
Expand Down
159 changes: 146 additions & 13 deletions api/src/controllers/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ 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 = 8;
const PASSWORD_MINIMUM_SCORE = 50;
Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved

const templates = {
login: {
Expand Down Expand Up @@ -51,6 +55,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',
'Passwords must match'
Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved
],
}
};

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 +229,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 @@ -213,7 +243,10 @@ const setCookies = (req, res, sessionRes) => {
const selectedLocale = req.body.locale
|| config.get('locale');
Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved
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 +333,70 @@ 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);

if (!(await skipPasswordChange(userCtx)) && userCtx.password_change_required){
Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved
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 +405,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 +433,61 @@ module.exports = {
});
},

passwordResetGet: (req, res, next) => {
Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved
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);
},
passwordResetPost: async (req, res) => {
Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved
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,
password_updated: true,
};

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
90 changes: 90 additions & 0 deletions api/src/public/login/auth-utils.js
Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
export const setState = function(className) {
document.getElementById('form').className = className;
};

export 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 extractCookie = function(cookies, name) {
for (const cookie of cookies) {
const [cookieName, cookieValue] = cookie.trim().split('=');
if (cookieName === name) {
return cookieValue.trim();
}
}
return null;
};

export const getCookie = function(name) {
if (!document.cookie) {
return null;
}

const cookies = document.cookie.split(';');
return extractCookie(cookies, name);
};

export const getLocale = function(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];
}
};

export const parseTranslations = function() {
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);
console.log(values);
Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved
return Object.entries(values).reduce((result, [key, value]) =>
Benmuiruri marked this conversation as resolved.
Show resolved Hide resolved
result.replace(new RegExp(`{{${key}}}`, 'g'), value),
text
);
} catch (e) {
console.error('Error parsing translation placeholders', e);
return text;
}
}

export 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')]);
};
Loading
Loading