diff --git a/packages/auth/package.json b/packages/auth/package.json index 583dc92b4c..5018f2a297 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -23,6 +23,7 @@ "test:coverage": "yarn test --coverage" }, "dependencies": { + "@node-rs/argon2": "^1.8.3", "@tupaia/server-utils": "workspace:*", "@tupaia/utils": "workspace:*", "jsonwebtoken": "^9.0.0", diff --git a/packages/auth/src/Authenticator.js b/packages/auth/src/Authenticator.js index b1d4c973ac..e0189f04da 100644 --- a/packages/auth/src/Authenticator.js +++ b/packages/auth/src/Authenticator.js @@ -1,6 +1,6 @@ /** * Tupaia - * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ import randomToken from 'rand-token'; import compareVersions from 'semver-compare'; @@ -8,7 +8,7 @@ import compareVersions from 'semver-compare'; import { DatabaseError, UnauthenticatedError, UnverifiedError } from '@tupaia/utils'; import { AccessPolicyBuilder } from './AccessPolicyBuilder'; import { mergeAccessPolicies } from './mergeAccessPolicies'; -import { encryptPassword } from './utils'; +import { verifyPassword } from './passwordEncryption'; import { getTokenClaims } from './userAuth'; const REFRESH_TOKEN_LENGTH = 40; @@ -47,12 +47,11 @@ export class Authenticator { * @param {{ username: string, secretKey: string }} apiClientCredentials */ async authenticateApiClient({ username, secretKey }) { - const secretKeyHash = encryptPassword(secretKey, process.env.API_CLIENT_SALT); const apiClient = await this.models.apiClient.findOne({ username, - secret_key_hash: secretKeyHash, }); - if (!apiClient) { + const verified = await verifyPassword(secretKey, apiClient.secret_key_hash); + if (!verified) { throw new UnauthenticatedError('Could not authenticate Api Client'); } const user = await apiClient.getUser(); diff --git a/packages/auth/src/index.js b/packages/auth/src/index.js index cef10e51d6..c7c1e86b4a 100644 --- a/packages/auth/src/index.js +++ b/packages/auth/src/index.js @@ -1,11 +1,10 @@ /** * Tupaia - * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ - export { Authenticator } from './Authenticator'; export { AccessPolicyBuilder } from './AccessPolicyBuilder'; -export * from './utils'; +export { encryptPassword, verifyPassword, sha256EncryptPassword } from './passwordEncryption'; export { getJwtToken, extractRefreshTokenFromReq, generateSecretKey } from './security'; export { getTokenClaimsFromBearerAuth, diff --git a/packages/auth/src/passwordEncryption.js b/packages/auth/src/passwordEncryption.js new file mode 100644 index 0000000000..e88f851811 --- /dev/null +++ b/packages/auth/src/passwordEncryption.js @@ -0,0 +1,35 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import { hash, verify } from '@node-rs/argon2'; +import sha256 from 'sha256'; + +/** + * Helper function to encrypt passwords using argon2 + * @param password {string} + * @returns {Promise} + */ +export function encryptPassword(password) { + return hash(password); +} + +/** + * Helper function to verify passwords using argon2 + * @param password + * @param hash {string} + * @returns {Promise} + */ +export async function verifyPassword(password, hash) { + return verify(hash, password); +} + +/** + * Helper function to encrypt passwords using sha256 + * @param password {string} + * @param salt {string} + * @returns {string} + */ +export function sha256EncryptPassword(password, salt) { + return sha256(`${password}${salt}`); +} diff --git a/packages/auth/src/utils.js b/packages/auth/src/utils.js deleted file mode 100644 index 5a7fdb706c..0000000000 --- a/packages/auth/src/utils.js +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Tupaia - * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd - */ - -import sha256 from 'sha256'; -import crypto from 'crypto'; - -/** - * Helper function to encrypt passwords using sha256 - */ -export function encryptPassword(password, salt) { - return sha256(`${password}${salt}`); -} - -/** - * Returns an object containing the encrypted form of a password, along with its random salt. - */ -export function hashAndSaltPassword(password) { - const salt = crypto.randomBytes(16).toString('base64'); // Generate a random salt - const encryptedPassword = encryptPassword(password, salt); - return { password_hash: encryptedPassword, password_salt: salt }; -} diff --git a/packages/central-server/src/apiV2/changePassword.js b/packages/central-server/src/apiV2/changePassword.js index 58af82c541..f3e1ccf6f9 100644 --- a/packages/central-server/src/apiV2/changePassword.js +++ b/packages/central-server/src/apiV2/changePassword.js @@ -3,7 +3,7 @@ * Copyright (c) 2017 Beyond Essential Systems Pty Ltd */ import { DatabaseError, FormValidationError, isValidPassword, respond } from '@tupaia/utils'; -import { hashAndSaltPassword } from '@tupaia/auth'; +import { encryptPassword } from '@tupaia/auth'; import { allowNoPermissions } from '../permissions'; export async function changePassword(req, res, next) { @@ -39,7 +39,7 @@ export async function changePassword(req, res, next) { if (!isTokenValid) { throw new FormValidationError('One time login is invalid'); } - } else if (!user.checkPassword(oldPassword)) { + } else if (!(await user.checkPassword(oldPassword))) { throw new FormValidationError('Incorrect current password', ['oldPassword']); } @@ -53,8 +53,9 @@ export async function changePassword(req, res, next) { throw new FormValidationError(error.message, ['password', 'passwordConfirm']); } + const newPasswordHash = await encryptPassword(passwordParam); await models.user.updateById(userId, { - ...hashAndSaltPassword(passwordParam), + password_hash: newPasswordHash, }); respond(res, { message: 'Password successfully updated' }); diff --git a/packages/central-server/src/apiV2/import/importUsers.js b/packages/central-server/src/apiV2/import/importUsers.js index a55041e9b9..0dff588711 100644 --- a/packages/central-server/src/apiV2/import/importUsers.js +++ b/packages/central-server/src/apiV2/import/importUsers.js @@ -14,10 +14,9 @@ import { constructIsOneOf, constructIsEmptyOr, } from '@tupaia/utils'; -import { hashAndSaltPassword } from '@tupaia/auth'; +import { encryptPassword } from '@tupaia/auth'; import { VerifiedEmail } from '@tupaia/types'; import { - TUPAIA_ADMIN_PANEL_PERMISSION_GROUP, assertAdminPanelAccessToCountry, assertAnyPermissions, assertBESAdminAccess, @@ -53,9 +52,11 @@ export async function importUsers(req, res) { } emails.push(userObject.email); const { password, permission_group: permissionGroupName, ...restOfUser } = userObject; + const newPasswordHash = await encryptPassword(password); + const userToUpsert = { ...restOfUser, - ...hashAndSaltPassword(password), + password_hash: newPasswordHash, }; const user = await transactingModels.user.updateOrCreate( { email: userObject.email }, diff --git a/packages/central-server/src/apiV2/middleware/auth/clientAuth.js b/packages/central-server/src/apiV2/middleware/auth/clientAuth.js index 0af89b46d1..3feaf69a69 100644 --- a/packages/central-server/src/apiV2/middleware/auth/clientAuth.js +++ b/packages/central-server/src/apiV2/middleware/auth/clientAuth.js @@ -1,10 +1,9 @@ /** - * Tupaia MediTrak - * Copyright (c) 2017 Beyond Essential Systems Pty Ltd + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ - import { UnauthenticatedError, requireEnv } from '@tupaia/utils'; -import { encryptPassword, getUserAndPassFromBasicAuth } from '@tupaia/auth'; +import { verifyPassword, getUserAndPassFromBasicAuth } from '@tupaia/auth'; export async function getAPIClientUser(authHeader, models) { const { username, password: secretKey } = getUserAndPassFromBasicAuth(authHeader); @@ -15,12 +14,11 @@ export async function getAPIClientUser(authHeader, models) { const API_CLIENT_SALT = requireEnv('API_CLIENT_SALT'); // We always need a valid client; throw if none is found - const secretKeyHash = encryptPassword(secretKey, API_CLIENT_SALT); const apiClient = await models.apiClient.findOne({ username, - secret_key_hash: secretKeyHash, }); - if (!apiClient) { + const verified = await verifyPassword(secretKey, apiClient.secret_key_hash); + if (!verified) { throw new UnauthenticatedError('Incorrect client username or secret'); } return apiClient.getUser(); diff --git a/packages/central-server/src/apiV2/userAccounts/CreateUserAccounts.js b/packages/central-server/src/apiV2/userAccounts/CreateUserAccounts.js index 541e584638..bb0998274b 100644 --- a/packages/central-server/src/apiV2/userAccounts/CreateUserAccounts.js +++ b/packages/central-server/src/apiV2/userAccounts/CreateUserAccounts.js @@ -1,17 +1,15 @@ -/** +/* * Tupaia - * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import { hashAndSaltPassword, encryptPassword, generateSecretKey } from '@tupaia/auth'; +import { encryptPassword, generateSecretKey } from '@tupaia/auth'; import { CreateHandler } from '../CreateHandler'; import { - TUPAIA_ADMIN_PANEL_PERMISSION_GROUP, assertAdminPanelAccess, assertAdminPanelAccessToCountry, assertAnyPermissions, assertBESAdminAccess, - hasTupaiaAdminPanelAccessToCountry, } from '../../permissions'; /** @@ -46,7 +44,7 @@ export class CreateUserAccounts extends CreateHandler { await transactingModels.apiClient.create({ username: user.email, user_account_id: user.id, - secret_key_hash: encryptPassword(secretKey, process.env.API_CLIENT_SALT), + secret_key_hash: await encryptPassword(secretKey), }); } @@ -103,13 +101,15 @@ export class CreateUserAccounts extends CreateHandler { ...restOfUser }, ) { + const passwordHash = await encryptPassword(password); + return transactingModels.user.create({ first_name: firstName, last_name: lastName, email: emailAddress, mobile_number: contactNumber, primary_platform: primaryPlatform, - ...hashAndSaltPassword(password), + password_hash: passwordHash, verified_email: verifiedEmail, ...restOfUser, }); diff --git a/packages/central-server/src/apiV2/userAccounts/EditUserAccounts.js b/packages/central-server/src/apiV2/userAccounts/EditUserAccounts.js index 10fbc9d812..ddc2658d53 100644 --- a/packages/central-server/src/apiV2/userAccounts/EditUserAccounts.js +++ b/packages/central-server/src/apiV2/userAccounts/EditUserAccounts.js @@ -1,9 +1,9 @@ -/** +/* * Tupaia - * Copyright (c) 2017 - 2020 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import { hashAndSaltPassword } from '@tupaia/auth'; +import { encryptPassword } from '@tupaia/auth'; import { S3Client, S3 } from '@tupaia/server-utils'; import { EditHandler } from '../EditHandler'; import { @@ -49,11 +49,12 @@ export class EditUserAccounts extends EditHandler { ...restOfUpdatedFields } = this.updatedFields; let updatedFields = restOfUpdatedFields; + const passwordAndSalt = await encryptPassword(password); if (password) { updatedFields = { ...updatedFields, - ...hashAndSaltPassword(password), + ...passwordAndSalt, }; } diff --git a/packages/central-server/src/apiV2/utilities/emailVerification.js b/packages/central-server/src/apiV2/utilities/emailVerification.js index 3f499712a7..8080bcf26f 100644 --- a/packages/central-server/src/apiV2/utilities/emailVerification.js +++ b/packages/central-server/src/apiV2/utilities/emailVerification.js @@ -1,9 +1,9 @@ /* * Tupaia - * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import { encryptPassword } from '@tupaia/auth'; +import { encryptPassword, verifyPassword } from '@tupaia/auth'; import { sendEmail } from '@tupaia/server-utils'; import { requireEnv } from '@tupaia/utils'; @@ -23,8 +23,10 @@ const EMAILS = { }, }; +const getEmailVerificationToken = user => `${user.email}${user.password_hash}`; + export const sendEmailVerification = async user => { - const token = encryptPassword(user.email + user.password_hash, user.password_salt); + const token = await encryptPassword(getEmailVerificationToken(user)); const platform = user.primary_platform ? user.primary_platform : 'tupaia'; const { subject, signOff, platformName } = EMAILS[platform]; const TUPAIA_FRONT_END_URL = requireEnv('TUPAIA_FRONT_END_URL'); @@ -59,5 +61,12 @@ export const verifyEmailHelper = async (models, searchCondition, token) => { verified_email: searchCondition, }); - return users.find(x => encryptPassword(x.email + x.password_hash, x.password_salt) === token); + for (const user of users) { + const verified = await verifyPassword(getEmailVerificationToken(user), token); + + if (verified) { + return user; + } + } + return null; }; diff --git a/packages/central-server/src/dataAccessors/createUser.js b/packages/central-server/src/dataAccessors/createUser.js index adb502d880..b5fb217b86 100644 --- a/packages/central-server/src/dataAccessors/createUser.js +++ b/packages/central-server/src/dataAccessors/createUser.js @@ -1,10 +1,10 @@ -/** - * Tupaia MediTrak - * Copyright (c) 2017 Beyond Essential Systems Pty Ltd +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ import { DatabaseError } from '@tupaia/utils'; -import { hashAndSaltPassword, encryptPassword, generateSecretKey } from '@tupaia/auth'; +import { encryptPassword, generateSecretKey } from '@tupaia/auth'; export const createUser = async ( models, @@ -39,12 +39,14 @@ export const createUser = async ( throw new Error(`No such country: ${countryName}`); } + const newPasswordHash = await encryptPassword(password); + const user = await transactingModels.user.create({ first_name: firstName, last_name: lastName, email: emailAddress, mobile_number: contactNumber, - ...hashAndSaltPassword(password), + password_hash: newPasswordHash, verified_email: verifiedEmail, ...restOfUser, }); @@ -61,7 +63,7 @@ export const createUser = async ( await transactingModels.apiClient.create({ username: user.email, user_account_id: user.id, - secret_key_hash: encryptPassword(secretKey, process.env.API_CLIENT_SALT), + secret_key_hash: await encryptPassword(secretKey), }); } diff --git a/packages/central-server/src/database/models/UserEntityPermission.js b/packages/central-server/src/database/models/UserEntityPermission.js index 2a7c19ba8f..015e9102b6 100644 --- a/packages/central-server/src/database/models/UserEntityPermission.js +++ b/packages/central-server/src/database/models/UserEntityPermission.js @@ -70,5 +70,5 @@ async function onUpsertSendPermissionGrantEmail( async function expireAccess({ new_record: newRecord, old_record: oldRecord }, models) { const userId = newRecord?.user_id || oldRecord.user_id; const user = await models.user.findById(userId); - await user.expireSessionToken('tupaia_web'); + await user?.expireSessionToken('tupaia_web'); } diff --git a/packages/central-server/src/tests/apiV2/authenticate/authenticate.test.js b/packages/central-server/src/tests/apiV2/authenticate/authenticate.test.js index bc1c886556..71d3287fa7 100644 --- a/packages/central-server/src/tests/apiV2/authenticate/authenticate.test.js +++ b/packages/central-server/src/tests/apiV2/authenticate/authenticate.test.js @@ -1,11 +1,14 @@ -/** - * Tupaia MediTrak - * Copyright (c) 2017 Beyond Essential Systems Pty Ltd +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ - import { expect } from 'chai'; - -import { encryptPassword, hashAndSaltPassword, getTokenClaims } from '@tupaia/auth'; +import { + encryptPassword, + getTokenClaims, + sha256EncryptPassword, + verifyPassword, +} from '@tupaia/auth'; import { findOrCreateDummyRecord, findOrCreateDummyCountryEntity } from '@tupaia/database'; import { createBasicHeader } from '@tupaia/utils'; @@ -39,11 +42,13 @@ describe('Authenticate', function () { }); // Create test users + const passwordHash = await encryptPassword(userAccountPassword); + userAccount = await findOrCreateDummyRecord(models.user, { first_name: 'Ash', last_name: 'Ketchum', email: 'ash-ketchum@pokemon.org', - ...hashAndSaltPassword(userAccountPassword), + password_hash: passwordHash, verified_email: VERIFIED, }); @@ -56,7 +61,7 @@ describe('Authenticate', function () { await findOrCreateDummyRecord(models.apiClient, { username: apiClientUserAccount.email, user_account_id: apiClientUserAccount.id, - secret_key_hash: encryptPassword(apiClientSecret, process.env.API_CLIENT_SALT), + secret_key_hash: await encryptPassword(apiClientSecret), }); // Public Demo Land Permission @@ -104,6 +109,75 @@ describe('Authenticate', function () { expect(apiClientUserId).to.equal(apiClientUserAccount.id); }); + it('Should authenticate user who has been migrated to argon2 password hashing', async () => { + const email = 'peeka@pokemon.org'; + const password = 'oldPassword123!'; + const salt = 'xyz123^'; + const sha256Hash = await sha256EncryptPassword(password, salt); + const argon2Hash = await encryptPassword(sha256Hash); + const migratedUser = await findOrCreateDummyRecord(models.user, { + first_name: 'Peeka', + last_name: 'Chu', + email: email, + password_hash: argon2Hash, + verified_email: VERIFIED, + }); + + const authResponse = await app.post('auth?grantType=password', { + headers: { + authorization: createBasicHeader(apiClientUserAccount.email, apiClientSecret), + }, + body: { + emailAddress: email, + password: password, + deviceName: 'test_device', + }, + }); + + expect(authResponse.status).to.equal(200); + const { accessToken, refreshToken, user: userDetails } = authResponse.body; + expect(accessToken).to.be.a('string'); + expect(refreshToken).to.be.a('string'); + expect(userDetails.id).to.equal(migratedUser.id); + expect(userDetails.email).to.equal(migratedUser.email); + }); + + it("Should migrate user's password to argon2 after successful login", async () => { + const email = 'squirtle@pokemon.org'; + const password = 'oldPassword123!'; + const salt = 'xyz123^'; + const sha256Hash = await sha256EncryptPassword(password, salt); + const argon2Hash = await encryptPassword(sha256Hash, salt); + const migratedUser = await findOrCreateDummyRecord(models.user, { + first_name: 'Peeka', + last_name: 'Chu', + email: email, + password_hash: argon2Hash, + password_salt: salt, + verified_email: VERIFIED, + }); + + const isVerifiedBefore = await verifyPassword(password, migratedUser.password_hash); + expect(isVerifiedBefore).to.be.false; + + const authResponse = await app.post('auth?grantType=password', { + headers: { + authorization: createBasicHeader(apiClientUserAccount.email, apiClientSecret), + }, + body: { + emailAddress: email, + password: password, + deviceName: 'test_device', + }, + }); + + expect(authResponse.status).to.equal(200); + + const userDetails = await models.user.findById(migratedUser.id); + const isVerifiedAfter = await verifyPassword(password, userDetails.password_hash); + expect(isVerifiedAfter).to.be.true; + }); + it('should add a new entry to the user_country_access_attempts table if one does not already exist', async () => { await app.post('auth?grantType=password', { headers: { diff --git a/packages/central-server/src/tests/apiV2/verifyEmail.test.js b/packages/central-server/src/tests/apiV2/verifyEmail.test.js index fab279bd50..e6fae77da0 100644 --- a/packages/central-server/src/tests/apiV2/verifyEmail.test.js +++ b/packages/central-server/src/tests/apiV2/verifyEmail.test.js @@ -1,9 +1,8 @@ -/** - * Tupaia MediTrak - * Copyright (c) 2017 Beyond Essential Systems Pty Ltd +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ import { expect } from 'chai'; - import { encryptPassword } from '@tupaia/auth'; import { randomEmail } from '@tupaia/utils'; import { getAuthorizationHeader, TestableApp } from '../testUtilities'; @@ -43,7 +42,7 @@ describe('Verify Email', () => { const verifyEmail = async userId => { const user = await models.user.findById(userId); - const token = encryptPassword(user.email + user.password_hash, user.password_salt); + const token = await encryptPassword(`${user.email}${user.password_hash}`); return app.post('auth/verifyEmail', { headers, diff --git a/packages/central-server/src/tests/testUtilities/database/addBaselineTestData.js b/packages/central-server/src/tests/testUtilities/database/addBaselineTestData.js index 9dcc5bac83..567134b592 100644 --- a/packages/central-server/src/tests/testUtilities/database/addBaselineTestData.js +++ b/packages/central-server/src/tests/testUtilities/database/addBaselineTestData.js @@ -1,6 +1,6 @@ -/** - * Tupaia MediTrak - * Copyright (c) 2019 Beyond Essential Systems Pty Ltd +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ import { encryptPassword } from '@tupaia/auth'; @@ -90,7 +90,7 @@ export async function addBaselineTestData() { }, { user_account_id: apiUser.userId, - secret_key_hash: encryptPassword(TEST_API_USER_PASSWORD, process.env.API_CLIENT_SALT), + secret_key_hash: await encryptPassword(TEST_API_USER_PASSWORD), }, ); } diff --git a/packages/database/src/migrations/20240902224836-argon2-passwords-modifies-schema.js b/packages/database/src/migrations/20240902224836-argon2-passwords-modifies-schema.js new file mode 100644 index 0000000000..eec0c24503 --- /dev/null +++ b/packages/database/src/migrations/20240902224836-argon2-passwords-modifies-schema.js @@ -0,0 +1,59 @@ +'use strict'; + +import { hash } from '@node-rs/argon2'; + +var dbm; +var type; +var seed; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; +}; + +exports.up = async function (db) { + await db.runSql(` + ALTER TABLE user_account + RENAME COLUMN password_hash TO password_hash_old; + ALTER TABLE user_account ALTER COLUMN password_hash_old DROP NOT NULL; + ALTER TABLE user_account ALTER COLUMN password_salt DROP NOT NULL; + + ALTER TABLE user_account + ADD COLUMN password_hash TEXT; + `); + const users = await db.runSql('SELECT id, password_hash_old FROM user_account'); + + await Promise.all( + users.rows.map(async user => { + const { id, password_hash_old } = user; + const hashedValue = await hash(password_hash_old); + await db.runSql('UPDATE user_account SET password_hash = $1 WHERE id = $2', [ + hashedValue, + id, + ]); + }), + ); + + await db.runSql(` + ALTER TABLE user_account ALTER COLUMN password_hash SET NOT NULL; + `); +}; + +exports.down = function (db) { + return db.runSql(` + ALTER TABLE user_account + DROP COLUMN password_hash; + + ALTER TABLE user_account + RENAME COLUMN password_hash_old TO password_hash; + `); +}; + +exports._meta = { + version: 1, +}; diff --git a/packages/database/src/modelClasses/User.js b/packages/database/src/modelClasses/User.js index fd87496f81..5291427e1a 100644 --- a/packages/database/src/modelClasses/User.js +++ b/packages/database/src/modelClasses/User.js @@ -1,9 +1,9 @@ -/** - * Tupaia MediTrak - * Copyright (c) 2017 Beyond Essential Systems Pty Ltd +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import { encryptPassword } from '@tupaia/auth'; - +import { verify } from '@node-rs/argon2'; +import { encryptPassword, verifyPassword, sha256EncryptPassword } from '@tupaia/auth'; import { DatabaseModel } from '../DatabaseModel'; import { DatabaseRecord } from '../DatabaseRecord'; import { RECORDS } from '../records'; @@ -19,9 +19,34 @@ export class UserRecord extends DatabaseRecord { return userFullName; } - // Checks if the provided non-encrypted password corresponds to this user - checkPassword(password) { - return encryptPassword(password, this.password_salt) === this.password_hash; + /** + * Attempts to verify the password using argon2, if that fails, it tries to verify the password + * using sha256 plus argon2. If the password is verified using sha256, the password is moved to + * argon2. + * @param password {string} + * @returns {Promise} + */ + async checkPassword(password) { + const salt = this.password_salt; + const hash = this.password_hash; + + // Try to verify password using argon2 directly + const isVerified = await verifyPassword(password, this.password_hash); + if (isVerified) { + return true; + } + + // Try to verify password using sha256 plus argon2 + const hashedUserInput = sha256EncryptPassword(password, salt); + const isVerifiedSha256 = await verify(hash, hashedUserInput); + if (isVerifiedSha256) { + // Move password to argon2 + const encryptedPassword = await encryptPassword(password); + await this.model.updateById(this.id, { password_hash: encryptedPassword }); + return true; + } + + return false; } checkIsEmailUnverified() { diff --git a/packages/entity-server/src/__tests__/testUtilities/setup.ts b/packages/entity-server/src/__tests__/testUtilities/setup.ts index ac2b0f936f..8cc482418b 100644 --- a/packages/entity-server/src/__tests__/testUtilities/setup.ts +++ b/packages/entity-server/src/__tests__/testUtilities/setup.ts @@ -1,11 +1,10 @@ /** * Tupaia - * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import { hashAndSaltPassword } from '@tupaia/auth'; +import { encryptPassword } from '@tupaia/auth'; import { TestableServer } from '@tupaia/server-boilerplate'; - import { findOrCreateDummyRecord, buildAndInsertProjectsAndHierarchies, @@ -45,6 +44,8 @@ export const setupTestData = async () => { const { VERIFIED } = models.user.emailVerifiedStatuses; + const passwordHash = await encryptPassword(userAccountPassword); + await findOrCreateDummyRecord( models.user, { @@ -53,7 +54,7 @@ export const setupTestData = async () => { { first_name: 'Ash', last_name: 'Ketchum', - ...hashAndSaltPassword(userAccountPassword), + password_hash: passwordHash, verified_email: VERIFIED, }, ); diff --git a/packages/meditrak-app-server/src/__tests__/utilities/setupTestUser.ts b/packages/meditrak-app-server/src/__tests__/utilities/setupTestUser.ts index 6bcc11aee7..4463c9f9d1 100644 --- a/packages/meditrak-app-server/src/__tests__/utilities/setupTestUser.ts +++ b/packages/meditrak-app-server/src/__tests__/utilities/setupTestUser.ts @@ -1,9 +1,9 @@ /** * Tupaia - * Copyright (c) 2017 - 2022 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import { hashAndSaltPassword } from '@tupaia/auth'; +import { encryptPassword } from '@tupaia/auth'; import { findOrCreateDummyRecord, getTestModels } from '@tupaia/database'; import { TestModelRegistry } from '../types'; import { CAT_USER } from '../__integration__/fixtures'; @@ -13,6 +13,7 @@ const models = getTestModels() as TestModelRegistry; export const setupTestUser = async () => { const { VERIFIED } = models.user.emailVerifiedStatuses; const { email, firstName, lastName, password } = CAT_USER; + const passwordHash = await encryptPassword(password); return findOrCreateDummyRecord( models.user, @@ -22,7 +23,7 @@ export const setupTestUser = async () => { { first_name: firstName, last_name: lastName, - ...hashAndSaltPassword(password), + password_hash: passwordHash, verified_email: VERIFIED, }, ); diff --git a/packages/report-server/src/__tests__/__integration__/testUtilities/setup.ts b/packages/report-server/src/__tests__/__integration__/testUtilities/setup.ts index 71eac262b6..98e387caf0 100644 --- a/packages/report-server/src/__tests__/__integration__/testUtilities/setup.ts +++ b/packages/report-server/src/__tests__/__integration__/testUtilities/setup.ts @@ -2,7 +2,7 @@ * Tupaia * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import { hashAndSaltPassword } from '@tupaia/auth'; +import { encryptPassword } from '@tupaia/auth'; import { TestableServer } from '@tupaia/server-boilerplate'; import { getTestModels, getTestDatabase, findOrCreateDummyRecord } from '@tupaia/database'; import { createBasicHeader } from '@tupaia/utils'; @@ -58,6 +58,8 @@ export const setupTestData = async () => { // add user const { VERIFIED } = models.user.emailVerifiedStatuses; + const passwordHash = await encryptPassword(userAccountPassword); + await findOrCreateDummyRecord( models.user, { @@ -66,7 +68,7 @@ export const setupTestData = async () => { { first_name: 'Ash', last_name: 'Ketchum', - ...hashAndSaltPassword(userAccountPassword), + password_hash: passwordHash, verified_email: VERIFIED, }, ); diff --git a/packages/server-boilerplate/src/utils/initialiseApiClient.ts b/packages/server-boilerplate/src/utils/initialiseApiClient.ts index 0e5bf859c0..403b3249b5 100644 --- a/packages/server-boilerplate/src/utils/initialiseApiClient.ts +++ b/packages/server-boilerplate/src/utils/initialiseApiClient.ts @@ -14,14 +14,12 @@ const upsertUserAccount = async ({ models, email, password, - salt, }: { models: ServerBoilerplateModelRegistry; email: string; password: string; - salt: string; }): Promise => { - const passwordHash = encryptPassword(password, salt); + const passwordHash = await encryptPassword(password); const firstName = email; const lastName = 'API Client'; @@ -35,7 +33,6 @@ const upsertUserAccount = async ({ last_name: lastName, email: email, password_hash: passwordHash, - password_salt: salt, }, ); return existingUserAccount.id; @@ -48,7 +45,6 @@ const upsertUserAccount = async ({ last_name: lastName, email: email, password_hash: passwordHash, - password_salt: salt, }); return newId; }; @@ -58,15 +54,13 @@ const upsertApiClient = async ({ userAccountId, username, password, - salt, }: { models: ServerBoilerplateModelRegistry; userAccountId: string; username: string; password: string; - salt: string; }) => { - const secretKeyHash = encryptPassword(password, salt); + const secretKeyHash = await encryptPassword(password); const existingApiClient = await models.apiClient.findOne({ username: username, @@ -142,21 +136,18 @@ export const initialiseApiClient = async ( ) => { const API_CLIENT_NAME = requireEnv('API_CLIENT_NAME'); const API_CLIENT_PASSWORD = requireEnv('API_CLIENT_PASSWORD'); - const API_CLIENT_SALT = requireEnv('API_CLIENT_SALT'); await models.wrapInTransaction(async (transactingModels: ServerBoilerplateModelRegistry) => { const userAccountId = await upsertUserAccount({ models: transactingModels, email: API_CLIENT_NAME, password: API_CLIENT_PASSWORD, - salt: API_CLIENT_SALT, }); await upsertApiClient({ models: transactingModels, userAccountId, username: API_CLIENT_NAME, password: API_CLIENT_PASSWORD, - salt: API_CLIENT_SALT, }); await upsertPermissions({ models: transactingModels, diff --git a/packages/tupaia-web-server/src/__tests__/testUtilities/setup.ts b/packages/tupaia-web-server/src/__tests__/testUtilities/setup.ts index 157ea45c1a..0d1ee6f0d5 100644 --- a/packages/tupaia-web-server/src/__tests__/testUtilities/setup.ts +++ b/packages/tupaia-web-server/src/__tests__/testUtilities/setup.ts @@ -1,9 +1,9 @@ /** * Tupaia - * Copyright (c) 2017 - 2023 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import { hashAndSaltPassword } from '@tupaia/auth'; +import { encryptPassword } from '@tupaia/auth'; import { createBasicHeader, requireEnv } from '@tupaia/utils'; import { TestableServer } from '@tupaia/server-boilerplate'; @@ -13,7 +13,6 @@ import { getTestModels, EntityHierarchyCacher, getTestDatabase, - findOrCreateDummyCountryEntity, } from '@tupaia/database'; import { createApp } from '../../app'; @@ -48,6 +47,7 @@ export const setupTestData = async () => { hierarchyCacher.stopListeningForChanges(); const { VERIFIED } = models.user.emailVerifiedStatuses; + const newPasswordHash = await encryptPassword(userAccountPassword); await findOrCreateDummyRecord( models.user, @@ -57,13 +57,15 @@ export const setupTestData = async () => { { first_name: 'Ash', last_name: 'Ketchum', - ...hashAndSaltPassword(userAccountPassword), + password_hash: newPasswordHash, verified_email: VERIFIED, }, ); const apiClientEmail = requireEnv('API_CLIENT_NAME'); const apiClientPassword = requireEnv('API_CLIENT_PASSWORD'); + const newApiClientPassword = await encryptPassword(apiClientPassword); + const apiClient = await findOrCreateDummyRecord( models.user, { @@ -72,7 +74,7 @@ export const setupTestData = async () => { { first_name: 'API', last_name: 'Client', - ...hashAndSaltPassword(apiClientPassword), + password_hash: newApiClientPassword, verified_email: VERIFIED, }, ); diff --git a/packages/types/src/schemas/schemas.ts b/packages/types/src/schemas/schemas.ts index 9a82832635..e45c631144 100644 --- a/packages/types/src/schemas/schemas.ts +++ b/packages/types/src/schemas/schemas.ts @@ -83976,6 +83976,9 @@ export const UserAccountSchema = { "password_hash": { "type": "string" }, + "password_hash_old": { + "type": "string" + }, "password_salt": { "type": "string" }, @@ -84023,7 +84026,6 @@ export const UserAccountSchema = { "email", "id", "password_hash", - "password_salt", "preferences" ] } @@ -84056,6 +84058,9 @@ export const UserAccountCreateSchema = { "password_hash": { "type": "string" }, + "password_hash_old": { + "type": "string" + }, "password_salt": { "type": "string" }, @@ -84101,8 +84106,7 @@ export const UserAccountCreateSchema = { "additionalProperties": false, "required": [ "email", - "password_hash", - "password_salt" + "password_hash" ] } @@ -84137,6 +84141,9 @@ export const UserAccountUpdateSchema = { "password_hash": { "type": "string" }, + "password_hash_old": { + "type": "string" + }, "password_salt": { "type": "string" }, diff --git a/packages/types/src/types/models.ts b/packages/types/src/types/models.ts index 07a1aa0e63..106e239aaf 100644 --- a/packages/types/src/types/models.ts +++ b/packages/types/src/types/models.ts @@ -1642,7 +1642,8 @@ export interface UserAccount { 'last_name'?: string | null; 'mobile_number'?: string | null; 'password_hash': string; - 'password_salt': string; + 'password_hash_old'?: string | null; + 'password_salt'?: string | null; 'position'?: string | null; 'preferences': UserAccountPreferences; 'primary_platform'?: PrimaryPlatform | null; @@ -1658,7 +1659,8 @@ export interface UserAccountCreate { 'last_name'?: string | null; 'mobile_number'?: string | null; 'password_hash': string; - 'password_salt': string; + 'password_hash_old'?: string | null; + 'password_salt'?: string | null; 'position'?: string | null; 'preferences'?: UserAccountPreferences; 'primary_platform'?: PrimaryPlatform | null; @@ -1675,7 +1677,8 @@ export interface UserAccountUpdate { 'last_name'?: string | null; 'mobile_number'?: string | null; 'password_hash'?: string; - 'password_salt'?: string; + 'password_hash_old'?: string | null; + 'password_salt'?: string | null; 'position'?: string | null; 'preferences'?: UserAccountPreferences; 'primary_platform'?: PrimaryPlatform | null; diff --git a/packages/web-config-server/src/authSession/getUserFromAuthHeader.js b/packages/web-config-server/src/authSession/getUserFromAuthHeader.js index a31f0658f7..242873d030 100644 --- a/packages/web-config-server/src/authSession/getUserFromAuthHeader.js +++ b/packages/web-config-server/src/authSession/getUserFromAuthHeader.js @@ -1,23 +1,25 @@ /** * Tupaia - * Copyright (c) 2017 - 2021 Beyond Essential Systems Pty Ltd + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ import { - encryptPassword, getUserAndPassFromBasicAuth, getTokenClaimsFromBearerAuth, + verifyPassword, } from '@tupaia/auth'; const getApiClientUserFromBasicAuth = async (models, authHeader) => { const { username, password: secretKey } = getUserAndPassFromBasicAuth(authHeader); // first attempt to authenticate as an api client, in case a secret key was used in the auth header - const secretKeyHash = encryptPassword(secretKey, process.env.API_CLIENT_SALT); const apiClient = await models.apiClient.findOne({ username, - secret_key_hash: secretKeyHash, }); - return apiClient?.getUser(); + if (!apiClient) { + return undefined; + } + const verified = await verifyPassword(secretKey, apiClient.secret_key_hash); + return verified ? apiClient?.getUser() : undefined; }; const getUserFromBearerAuth = async (models, authHeader) => { diff --git a/yarn.lock b/yarn.lock index b49907f617..912c7e92d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7000,6 +7000,34 @@ __metadata: languageName: node linkType: hard +"@emnapi/core@npm:^1.1.0": + version: 1.2.0 + resolution: "@emnapi/core@npm:1.2.0" + dependencies: + "@emnapi/wasi-threads": 1.0.1 + tslib: ^2.4.0 + checksum: b3b61bd01de93346f05803151eee9dc308262065034d835db95a46842ea75867c43745c227577f19fa0542fcb3883a752477eb012bf9e4b72f540f4e23f63cbe + languageName: node + linkType: hard + +"@emnapi/runtime@npm:^1.1.0": + version: 1.2.0 + resolution: "@emnapi/runtime@npm:1.2.0" + dependencies: + tslib: ^2.4.0 + checksum: c9f5814f65a7851eda3fae96320b7ebfaf3b7e0db4e1ac2d77b55f5c0785e56b459a029413dbfc0abb1b23f059b850169888f92833150a28cdf24b9a53e535c5 + languageName: node + linkType: hard + +"@emnapi/wasi-threads@npm:1.0.1": + version: 1.0.1 + resolution: "@emnapi/wasi-threads@npm:1.0.1" + dependencies: + tslib: ^2.4.0 + checksum: e154880440ff9bfe67b417f30134f0ff6fee28913dbf4a22de2e67dda5bf5b51055647c5d1565281df17ef5dfcc89256546bdf9b8ccfd07e07566617e7ce1498 + languageName: node + linkType: hard + "@emotion/hash@npm:^0.8.0": version: 0.8.0 resolution: "@emotion/hash@npm:0.8.0" @@ -8308,6 +8336,17 @@ __metadata: languageName: node linkType: hard +"@napi-rs/wasm-runtime@npm:^0.2.3": + version: 0.2.4 + resolution: "@napi-rs/wasm-runtime@npm:0.2.4" + dependencies: + "@emnapi/core": ^1.1.0 + "@emnapi/runtime": ^1.1.0 + "@tybys/wasm-util": ^0.9.0 + checksum: 976eeca9c411724bf004f92a94707f1c78b6a5932a354e8b456eaae16c476dd6b96244c4afec60a3f621c922fca3ef2c6c3f6a900bd6b79f509dd4c0c2b3376d + languageName: node + linkType: hard + "@nicolo-ribaudo/chokidar-2@npm:2.1.8-no-fsevents.3": version: 2.1.8-no-fsevents.3 resolution: "@nicolo-ribaudo/chokidar-2@npm:2.1.8-no-fsevents.3" @@ -8324,6 +8363,157 @@ __metadata: languageName: node linkType: hard +"@node-rs/argon2-android-arm-eabi@npm:1.8.3": + version: 1.8.3 + resolution: "@node-rs/argon2-android-arm-eabi@npm:1.8.3" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@node-rs/argon2-android-arm64@npm:1.8.3": + version: 1.8.3 + resolution: "@node-rs/argon2-android-arm64@npm:1.8.3" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@node-rs/argon2-darwin-arm64@npm:1.8.3": + version: 1.8.3 + resolution: "@node-rs/argon2-darwin-arm64@npm:1.8.3" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@node-rs/argon2-darwin-x64@npm:1.8.3": + version: 1.8.3 + resolution: "@node-rs/argon2-darwin-x64@npm:1.8.3" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@node-rs/argon2-freebsd-x64@npm:1.8.3": + version: 1.8.3 + resolution: "@node-rs/argon2-freebsd-x64@npm:1.8.3" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@node-rs/argon2-linux-arm-gnueabihf@npm:1.8.3": + version: 1.8.3 + resolution: "@node-rs/argon2-linux-arm-gnueabihf@npm:1.8.3" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@node-rs/argon2-linux-arm64-gnu@npm:1.8.3": + version: 1.8.3 + resolution: "@node-rs/argon2-linux-arm64-gnu@npm:1.8.3" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@node-rs/argon2-linux-arm64-musl@npm:1.8.3": + version: 1.8.3 + resolution: "@node-rs/argon2-linux-arm64-musl@npm:1.8.3" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@node-rs/argon2-linux-x64-gnu@npm:1.8.3": + version: 1.8.3 + resolution: "@node-rs/argon2-linux-x64-gnu@npm:1.8.3" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@node-rs/argon2-linux-x64-musl@npm:1.8.3": + version: 1.8.3 + resolution: "@node-rs/argon2-linux-x64-musl@npm:1.8.3" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@node-rs/argon2-wasm32-wasi@npm:1.8.3": + version: 1.8.3 + resolution: "@node-rs/argon2-wasm32-wasi@npm:1.8.3" + dependencies: + "@napi-rs/wasm-runtime": ^0.2.3 + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@node-rs/argon2-win32-arm64-msvc@npm:1.8.3": + version: 1.8.3 + resolution: "@node-rs/argon2-win32-arm64-msvc@npm:1.8.3" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@node-rs/argon2-win32-ia32-msvc@npm:1.8.3": + version: 1.8.3 + resolution: "@node-rs/argon2-win32-ia32-msvc@npm:1.8.3" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@node-rs/argon2-win32-x64-msvc@npm:1.8.3": + version: 1.8.3 + resolution: "@node-rs/argon2-win32-x64-msvc@npm:1.8.3" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@node-rs/argon2@npm:^1.8.3": + version: 1.8.3 + resolution: "@node-rs/argon2@npm:1.8.3" + dependencies: + "@node-rs/argon2-android-arm-eabi": 1.8.3 + "@node-rs/argon2-android-arm64": 1.8.3 + "@node-rs/argon2-darwin-arm64": 1.8.3 + "@node-rs/argon2-darwin-x64": 1.8.3 + "@node-rs/argon2-freebsd-x64": 1.8.3 + "@node-rs/argon2-linux-arm-gnueabihf": 1.8.3 + "@node-rs/argon2-linux-arm64-gnu": 1.8.3 + "@node-rs/argon2-linux-arm64-musl": 1.8.3 + "@node-rs/argon2-linux-x64-gnu": 1.8.3 + "@node-rs/argon2-linux-x64-musl": 1.8.3 + "@node-rs/argon2-wasm32-wasi": 1.8.3 + "@node-rs/argon2-win32-arm64-msvc": 1.8.3 + "@node-rs/argon2-win32-ia32-msvc": 1.8.3 + "@node-rs/argon2-win32-x64-msvc": 1.8.3 + dependenciesMeta: + "@node-rs/argon2-android-arm-eabi": + optional: true + "@node-rs/argon2-android-arm64": + optional: true + "@node-rs/argon2-darwin-arm64": + optional: true + "@node-rs/argon2-darwin-x64": + optional: true + "@node-rs/argon2-freebsd-x64": + optional: true + "@node-rs/argon2-linux-arm-gnueabihf": + optional: true + "@node-rs/argon2-linux-arm64-gnu": + optional: true + "@node-rs/argon2-linux-arm64-musl": + optional: true + "@node-rs/argon2-linux-x64-gnu": + optional: true + "@node-rs/argon2-linux-x64-musl": + optional: true + "@node-rs/argon2-wasm32-wasi": + optional: true + "@node-rs/argon2-win32-arm64-msvc": + optional: true + "@node-rs/argon2-win32-ia32-msvc": + optional: true + "@node-rs/argon2-win32-x64-msvc": + optional: true + checksum: ee42315e94205f22205d6ba3569e4a2f6290f3f97e6c89ac2d2a6cca3e9f1fd9d9423bb096970d2238a34ee6a0df90b1a49878194db6cb9ae9150d4eee45688e + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.3": version: 2.1.3 resolution: "@nodelib/fs.scandir@npm:2.1.3" @@ -9895,6 +10085,7 @@ __metadata: resolution: "@tupaia/auth@workspace:packages/auth" dependencies: "@beyondessential/tupaia-access-policy": ^2.5.1 + "@node-rs/argon2": ^1.8.3 "@tupaia/access-policy": "workspace:*" "@tupaia/database": "workspace:*" "@tupaia/server-utils": "workspace:*" @@ -10901,6 +11092,15 @@ __metadata: languageName: unknown linkType: soft +"@tybys/wasm-util@npm:^0.9.0": + version: 0.9.0 + resolution: "@tybys/wasm-util@npm:0.9.0" + dependencies: + tslib: ^2.4.0 + checksum: 8d44c64e64e39c746e45b5dff7b534716f20e1f6e8fc206f8e4c8ac454ec0eb35b65646e446dd80745bc898db37a4eca549a936766d447c2158c9c43d44e7708 + languageName: node + linkType: hard + "@types/api-error-handler@npm:^1.0.32": version: 1.0.32 resolution: "@types/api-error-handler@npm:1.0.32"