diff --git a/keyserver/src/responders/user-responders.js b/keyserver/src/responders/user-responders.js index 1a879bf3a..632339338 100644 --- a/keyserver/src/responders/user-responders.js +++ b/keyserver/src/responders/user-responders.js @@ -1,940 +1,940 @@ // @flow import type { Utility as OlmUtility } from '@commapp/olm'; import invariant from 'invariant'; import { getRustAPI } from 'rust-node-addon'; import { ErrorTypes, SiweMessage } from 'siwe'; import t, { type TInterface, type TUnion, type TEnums } from 'tcomb'; import bcrypt from 'twin-bcrypt'; import { baseLegalPolicies, policies, policyTypeValidator, } from 'lib/facts/policies.js'; import { mixedRawThreadInfoValidator } from 'lib/permissions/minimally-encoded-raw-thread-info-validators.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { KeyserverAuthRequest, ResetPasswordRequest, LogOutResponse, RegisterResponse, RegisterRequest, ServerLogInResponse, LogInRequest, UpdatePasswordRequest, UpdateUserSettingsRequest, PolicyAcknowledgmentRequest, ClaimUsernameResponse, } from 'lib/types/account-types'; import { userSettingsTypes, notificationTypeValues, authActionSources, } from 'lib/types/account-types.js'; import { type ClientAvatar, clientAvatarValidator, type UpdateUserAvatarResponse, type UpdateUserAvatarRequest, } from 'lib/types/avatar-types.js'; import type { ReservedUsernameMessage, IdentityKeysBlob, SignedIdentityKeysBlob, } from 'lib/types/crypto-types.js'; import type { DeviceType } from 'lib/types/device-types'; import { type CalendarQuery, rawEntryInfoValidator, type FetchEntryInfosBase, } from 'lib/types/entry-types.js'; import { defaultNumberPerThread, rawMessageInfoValidator, messageTruncationStatusesValidator, } from 'lib/types/message-types.js'; import type { SIWEAuthRequest, SIWEMessage, SIWESocialProof, } from 'lib/types/siwe-types.js'; import { type SubscriptionUpdateRequest, type SubscriptionUpdateResponse, threadSubscriptionValidator, } from 'lib/types/subscription-types.js'; import { createUpdatesResultValidator } from 'lib/types/update-types.js'; import { type PasswordUpdate, loggedOutUserInfoValidator, loggedInUserInfoValidator, userInfoValidator, } from 'lib/types/user-types.js'; import { identityKeysBlobValidator, signedIdentityKeysBlobValidator, } from 'lib/utils/crypto-utils.js'; import { ServerError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { getPublicKeyFromSIWEStatement, isValidSIWEMessage, isValidSIWEStatementWithPublicKey, primaryIdentityPublicKeyRegex, } from 'lib/utils/siwe-utils.js'; import { tShape, tPlatformDetails, tPassword, tEmail, tOldValidUsername, tRegex, tID, } from 'lib/utils/validation-utils.js'; import { entryQueryInputValidator, newEntryQueryInputValidator, normalizeCalendarQuery, verifyCalendarQueryThreadIDs, } from './entry-responders.js'; import { processOLMAccountCreation, createAccount, processSIWEAccountCreation, } from '../creators/account-creator.js'; import { createOlmSession, persistFreshOlmSession, createAndPersistOlmSession, } from '../creators/olm-session-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { deleteAccount } from '../deleters/account-deleters.js'; import { deleteCookie } from '../deleters/cookie-deleters.js'; import { checkAndInvalidateSIWENonceEntry } from '../deleters/siwe-nonce-deleters.js'; import { fetchEntryInfos } from '../fetchers/entry-fetchers.js'; import { fetchMessageInfos } from '../fetchers/message-fetchers.js'; import { fetchNotAcknowledgedPolicies } from '../fetchers/policy-acknowledgment-fetchers.js'; import { fetchThreadInfos } from '../fetchers/thread-fetchers.js'; import { fetchKnownUserInfos, fetchLoggedInUserInfo, fetchUserIDForEthereumAddress, fetchUsername, } from '../fetchers/user-fetchers.js'; import { createNewAnonymousCookie, createNewUserCookie, setNewSession, } from '../session/cookies.js'; import type { Viewer } from '../session/viewer.js'; import { - accountUpdater, + passwordUpdater, checkAndSendVerificationEmail, checkAndSendPasswordResetEmail, updatePassword, updateUserSettings, updateUserAvatar, } from '../updaters/account-updaters.js'; import { fetchOlmAccount } from '../updaters/olm-account-updater.js'; import { userSubscriptionUpdater } from '../updaters/user-subscription-updaters.js'; import { viewerAcknowledgmentUpdater } from '../updaters/viewer-acknowledgment-updater.js'; import { verifyUserLoggedIn } from '../user/login.js'; import { getOlmUtility, getContentSigningKey } from '../utils/olm-utils.js'; export const subscriptionUpdateRequestInputValidator: TInterface = tShape({ threadID: tID, updatedFields: tShape({ pushNotifs: t.maybe(t.Boolean), home: t.maybe(t.Boolean), }), }); export const subscriptionUpdateResponseValidator: TInterface = tShape({ threadSubscription: threadSubscriptionValidator, }); async function userSubscriptionUpdateResponder( viewer: Viewer, request: SubscriptionUpdateRequest, ): Promise { const threadSubscription = await userSubscriptionUpdater(viewer, request); return { threadSubscription, }; } export const accountUpdateInputValidator: TInterface = tShape({ updatedFields: tShape({ email: t.maybe(tEmail), password: t.maybe(tPassword), }), currentPassword: tPassword, }); async function passwordUpdateResponder( viewer: Viewer, request: PasswordUpdate, ): Promise { - await accountUpdater(viewer, request); + await passwordUpdater(viewer, request); } async function sendVerificationEmailResponder(viewer: Viewer): Promise { await checkAndSendVerificationEmail(viewer); } export const resetPasswordRequestInputValidator: TInterface = tShape({ usernameOrEmail: t.union([tEmail, tOldValidUsername]), }); async function sendPasswordResetEmailResponder( viewer: Viewer, request: ResetPasswordRequest, ): Promise { await checkAndSendPasswordResetEmail(request); } export const logOutResponseValidator: TInterface = tShape({ currentUserInfo: loggedOutUserInfoValidator, }); async function logOutResponder(viewer: Viewer): Promise { if (viewer.loggedIn) { const [anonymousViewerData] = await Promise.all([ createNewAnonymousCookie({ platformDetails: viewer.platformDetails, deviceToken: viewer.deviceToken, }), deleteCookie(viewer.cookieID), ]); viewer.setNewCookie(anonymousViewerData); } return { currentUserInfo: { anonymous: true, }, }; } async function accountDeletionResponder( viewer: Viewer, ): Promise { const result = await deleteAccount(viewer); invariant(result, 'deleteAccount should return result if handed request'); return result; } type OldDeviceTokenUpdateRequest = { +deviceType?: ?DeviceType, +deviceToken: string, }; const deviceTokenUpdateRequestInputValidator = tShape({ deviceType: t.maybe(t.enums.of(['ios', 'android'])), deviceToken: t.String, }); export const registerRequestInputValidator: TInterface = tShape({ username: t.String, email: t.maybe(tEmail), password: tPassword, calendarQuery: t.maybe(newEntryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, // We include `primaryIdentityPublicKey` to avoid breaking // old clients, but we no longer do anything with it. primaryIdentityPublicKey: t.maybe(tRegex(primaryIdentityPublicKeyRegex)), signedIdentityKeysBlob: t.maybe(signedIdentityKeysBlobValidator), initialNotificationsEncryptedMessage: t.maybe(t.String), }); export const registerResponseValidator: TInterface = tShape({ id: t.String, rawMessageInfos: t.list(rawMessageInfoValidator), currentUserInfo: loggedInUserInfoValidator, cookieChange: tShape({ threadInfos: t.dict(tID, mixedRawThreadInfoValidator), userInfos: t.list(userInfoValidator), }), }); async function accountCreationResponder( viewer: Viewer, request: RegisterRequest, ): Promise { const { signedIdentityKeysBlob } = request; if (signedIdentityKeysBlob) { const identityKeys: IdentityKeysBlob = JSON.parse( signedIdentityKeysBlob.payload, ); if (!identityKeysBlobValidator.is(identityKeys)) { throw new ServerError('invalid_identity_keys_blob'); } const olmUtil: OlmUtility = getOlmUtility(); try { olmUtil.ed25519_verify( identityKeys.primaryIdentityPublicKeys.ed25519, signedIdentityKeysBlob.payload, signedIdentityKeysBlob.signature, ); } catch (e) { throw new ServerError('invalid_signature'); } } return await createAccount(viewer, request); } type ProcessSuccessfulLoginParams = { +viewer: Viewer, +input: any, +userID: string, +calendarQuery: ?CalendarQuery, +socialProof?: ?SIWESocialProof, +signedIdentityKeysBlob?: ?SignedIdentityKeysBlob, +initialNotificationsEncryptedMessage?: string, +pickledContentOlmSession?: string, +cookieHasBeenSet?: boolean, }; async function processSuccessfulLogin( params: ProcessSuccessfulLoginParams, ): Promise { const { viewer, input, userID, calendarQuery, socialProof, signedIdentityKeysBlob, initialNotificationsEncryptedMessage, pickledContentOlmSession, cookieHasBeenSet, } = params; const request: LogInRequest = input; const newServerTime = Date.now(); const deviceToken = request.deviceTokenUpdateRequest ? request.deviceTokenUpdateRequest.deviceToken : viewer.deviceToken; const setNewCookiePromise = (async () => { if (cookieHasBeenSet) { return; } const [userViewerData] = await Promise.all([ createNewUserCookie(userID, { platformDetails: request.platformDetails, deviceToken, socialProof, signedIdentityKeysBlob, }), deleteCookie(viewer.cookieID), ]); viewer.setNewCookie(userViewerData); })(); const [notAcknowledgedPolicies] = await Promise.all([ fetchNotAcknowledgedPolicies(userID, baseLegalPolicies), setNewCookiePromise, ]); if ( notAcknowledgedPolicies.length && hasMinCodeVersion(viewer.platformDetails, { native: 181 }) ) { const currentUserInfo = await fetchLoggedInUserInfo(viewer); return { notAcknowledgedPolicies, currentUserInfo: currentUserInfo, rawMessageInfos: [], truncationStatuses: {}, userInfos: [], rawEntryInfos: [], serverTime: 0, cookieChange: { threadInfos: {}, userInfos: [], }, }; } if (calendarQuery) { await setNewSession(viewer, calendarQuery, newServerTime); } const olmNotifSessionPromise = (async () => { if ( viewer.cookieID && initialNotificationsEncryptedMessage && signedIdentityKeysBlob ) { await createAndPersistOlmSession( initialNotificationsEncryptedMessage, 'notifications', viewer.cookieID, ); } })(); // `pickledContentOlmSession` is created in `keyserverAuthResponder(...)` in // order to authenticate the user. Here, we simply persist the session if it // exists. const olmContentSessionPromise = (async () => { if (viewer.cookieID && pickledContentOlmSession) { await persistFreshOlmSession( pickledContentOlmSession, 'content', viewer.cookieID, ); } })(); const threadCursors: { [string]: null } = {}; for (const watchedThreadID of request.watchedIDs) { threadCursors[watchedThreadID] = null; } const messageSelectionCriteria = { threadCursors, joinedThreads: true }; const entriesPromise: Promise = (async () => { if (!calendarQuery) { return undefined; } return await fetchEntryInfos(viewer, [calendarQuery]); })(); const [ threadsResult, messagesResult, entriesResult, userInfos, currentUserInfo, ] = await Promise.all([ fetchThreadInfos(viewer), fetchMessageInfos(viewer, messageSelectionCriteria, defaultNumberPerThread), entriesPromise, fetchKnownUserInfos(viewer), fetchLoggedInUserInfo(viewer), olmNotifSessionPromise, olmContentSessionPromise, ]); const rawEntryInfos = entriesResult ? entriesResult.rawEntryInfos : null; const response: ServerLogInResponse = { currentUserInfo, rawMessageInfos: messagesResult.rawMessageInfos, truncationStatuses: messagesResult.truncationStatuses, serverTime: newServerTime, userInfos: values(userInfos), cookieChange: { threadInfos: threadsResult.threadInfos, userInfos: [], }, }; if (rawEntryInfos) { return { ...response, rawEntryInfos, }; } return response; } export const logInRequestInputValidator: TInterface = tShape({ username: t.maybe(t.String), usernameOrEmail: t.maybe(t.union([tEmail, tOldValidUsername])), password: tPassword, watchedIDs: t.list(tID), calendarQuery: t.maybe(entryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, source: t.maybe(t.enums.of(values(authActionSources))), // We include `primaryIdentityPublicKey` to avoid breaking // old clients, but we no longer do anything with it. primaryIdentityPublicKey: t.maybe(tRegex(primaryIdentityPublicKeyRegex)), signedIdentityKeysBlob: t.maybe(signedIdentityKeysBlobValidator), initialNotificationsEncryptedMessage: t.maybe(t.String), }); export const logInResponseValidator: TInterface = tShape({ currentUserInfo: loggedInUserInfoValidator, rawMessageInfos: t.list(rawMessageInfoValidator), truncationStatuses: messageTruncationStatusesValidator, userInfos: t.list(userInfoValidator), rawEntryInfos: t.maybe(t.list(rawEntryInfoValidator)), serverTime: t.Number, cookieChange: tShape({ threadInfos: t.dict(tID, mixedRawThreadInfoValidator), userInfos: t.list(userInfoValidator), }), notAcknowledgedPolicies: t.maybe(t.list(policyTypeValidator)), }); async function logInResponder( viewer: Viewer, request: LogInRequest, ): Promise { let identityKeys: ?IdentityKeysBlob; const { signedIdentityKeysBlob, initialNotificationsEncryptedMessage } = request; if (signedIdentityKeysBlob) { identityKeys = JSON.parse(signedIdentityKeysBlob.payload); const olmUtil: OlmUtility = getOlmUtility(); try { olmUtil.ed25519_verify( identityKeys.primaryIdentityPublicKeys.ed25519, signedIdentityKeysBlob.payload, signedIdentityKeysBlob.signature, ); } catch (e) { throw new ServerError('invalid_signature'); } } const calendarQuery = request.calendarQuery ? normalizeCalendarQuery(request.calendarQuery) : null; const verifyCalendarQueryThreadIDsPromise = (async () => { if (calendarQuery) { await verifyCalendarQueryThreadIDs(calendarQuery); } })(); const username = request.username ?? request.usernameOrEmail; if (!username) { if (hasMinCodeVersion(viewer.platformDetails, { native: 150 })) { throw new ServerError('invalid_credentials'); } else { throw new ServerError('invalid_parameters'); } } const userQuery = SQL` SELECT id, hash, username FROM users WHERE LCASE(username) = LCASE(${username}) `; const userQueryPromise = dbQuery(userQuery); const [[userResult]] = await Promise.all([ userQueryPromise, verifyCalendarQueryThreadIDsPromise, ]); if (userResult.length === 0) { if (hasMinCodeVersion(viewer.platformDetails, { native: 150 })) { throw new ServerError('invalid_credentials'); } else { throw new ServerError('invalid_parameters'); } } const userRow = userResult[0]; if (!userRow.hash || !bcrypt.compareSync(request.password, userRow.hash)) { throw new ServerError('invalid_credentials'); } const id = userRow.id.toString(); return await processSuccessfulLogin({ viewer, input: request, userID: id, calendarQuery, signedIdentityKeysBlob, initialNotificationsEncryptedMessage, }); } export const siweAuthRequestInputValidator: TInterface = tShape({ signature: t.String, message: t.String, calendarQuery: entryQueryInputValidator, deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, watchedIDs: t.list(tID), signedIdentityKeysBlob: t.maybe(signedIdentityKeysBlobValidator), initialNotificationsEncryptedMessage: t.maybe(t.String), doNotRegister: t.maybe(t.Boolean), }); async function siweAuthResponder( viewer: Viewer, request: SIWEAuthRequest, ): Promise { const { message, signature, deviceTokenUpdateRequest, platformDetails, signedIdentityKeysBlob, initialNotificationsEncryptedMessage, doNotRegister, } = request; const calendarQuery = normalizeCalendarQuery(request.calendarQuery); // 1. Ensure that `message` is a well formed Comm SIWE Auth message. const siweMessage: SIWEMessage = new SiweMessage(message); if (!isValidSIWEMessage(siweMessage)) { throw new ServerError('invalid_parameters'); } // 2. Check if there's already a user for this ETH address. const existingUserID = await fetchUserIDForEthereumAddress( siweMessage.address, ); if (!existingUserID && doNotRegister) { throw new ServerError('account_does_not_exist'); } // 3. Ensure that the `nonce` exists in the `siwe_nonces` table // AND hasn't expired. If those conditions are met, delete the entry to // ensure that the same `nonce` can't be re-used in a future request. const wasNonceCheckedAndInvalidated = await checkAndInvalidateSIWENonceEntry( siweMessage.nonce, ); if (!wasNonceCheckedAndInvalidated) { throw new ServerError('invalid_parameters'); } // 4. Validate SIWEMessage signature and handle possible errors. try { await siweMessage.validate(signature); } catch (error) { if (error === ErrorTypes.EXPIRED_MESSAGE) { // Thrown when the `expirationTime` is present and in the past. throw new ServerError('expired_message'); } else if (error === ErrorTypes.INVALID_SIGNATURE) { // Thrown when the `validate()` function can't verify the message. throw new ServerError('invalid_signature'); } else if (error === ErrorTypes.MALFORMED_SESSION) { // Thrown when some required field is missing. throw new ServerError('malformed_session'); } else { throw new ServerError('unknown_error'); } } // 5. Pull `primaryIdentityPublicKey` out from SIWEMessage `statement`. // We expect it to be included for BOTH native and web clients. const { statement } = siweMessage; const primaryIdentityPublicKey = statement && isValidSIWEStatementWithPublicKey(statement) ? getPublicKeyFromSIWEStatement(statement) : null; if (!primaryIdentityPublicKey) { throw new ServerError('invalid_siwe_statement_public_key'); } // 6. Verify `signedIdentityKeysBlob.payload` with included `signature` // if `signedIdentityKeysBlob` was included in the `SIWEAuthRequest`. let identityKeys: ?IdentityKeysBlob; if (signedIdentityKeysBlob) { identityKeys = JSON.parse(signedIdentityKeysBlob.payload); if (!identityKeysBlobValidator.is(identityKeys)) { throw new ServerError('invalid_identity_keys_blob'); } const olmUtil: OlmUtility = getOlmUtility(); try { olmUtil.ed25519_verify( identityKeys.primaryIdentityPublicKeys.ed25519, signedIdentityKeysBlob.payload, signedIdentityKeysBlob.signature, ); } catch (e) { throw new ServerError('invalid_signature'); } } // 7. Ensure that `primaryIdentityPublicKeys.ed25519` matches SIWE // statement `primaryIdentityPublicKey` if `identityKeys` exists. if ( identityKeys && identityKeys.primaryIdentityPublicKeys.ed25519 !== primaryIdentityPublicKey ) { throw new ServerError('primary_public_key_mismatch'); } // 8. Construct `SIWESocialProof` object with the stringified // SIWEMessage and the corresponding signature. const socialProof: SIWESocialProof = { siweMessage: siweMessage.toMessage(), siweMessageSignature: signature, }; // 9. Create account with call to `processSIWEAccountCreation(...)` // if address does not correspond to an existing user. let userID = existingUserID; if (!userID) { const siweAccountCreationRequest = { address: siweMessage.address, calendarQuery, deviceTokenUpdateRequest, platformDetails, socialProof, }; userID = await processSIWEAccountCreation( viewer, siweAccountCreationRequest, ); } // 10. Complete login with call to `processSuccessfulLogin(...)`. return await processSuccessfulLogin({ viewer, input: request, userID, calendarQuery, socialProof, signedIdentityKeysBlob, initialNotificationsEncryptedMessage, }); } export const keyserverAuthRequestInputValidator: TInterface = tShape({ userID: t.String, deviceID: t.String, calendarQuery: entryQueryInputValidator, deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, watchedIDs: t.list(tID), initialContentEncryptedMessage: t.String, initialNotificationsEncryptedMessage: t.String, doNotRegister: t.Boolean, source: t.maybe(t.enums.of(values(authActionSources))), }); async function keyserverAuthResponder( viewer: Viewer, request: KeyserverAuthRequest, ): Promise { const { userID, deviceID, deviceTokenUpdateRequest, platformDetails, initialContentEncryptedMessage, initialNotificationsEncryptedMessage, doNotRegister, } = request; const calendarQuery = normalizeCalendarQuery(request.calendarQuery); // 1. Check if there's already a user for this userID. Simultaneously, get // info for identity service auth. const [existingUsername, authDeviceID, identityInfo, rustAPI] = await Promise.all([ fetchUsername(userID), getContentSigningKey(), verifyUserLoggedIn(), getRustAPI(), ]); if (!existingUsername && doNotRegister) { throw new ServerError('account_does_not_exist'); } if (!identityInfo) { throw new ServerError('account_not_registered_on_identity_service'); } // 2. Get user's keys from identity service. let inboundKeysForUser; try { inboundKeysForUser = await rustAPI.getInboundKeysForUserDevice( identityInfo.userId, authDeviceID, identityInfo.accessToken, userID, deviceID, ); } catch (e) { throw new ServerError('failed_to_retrieve_inbound_keys'); } const username = inboundKeysForUser.username ? inboundKeysForUser.username : inboundKeysForUser.walletAddress; if (!username) { throw new ServerError('user_identifier_missing'); } const identityKeys: IdentityKeysBlob = JSON.parse(inboundKeysForUser.payload); if (!identityKeysBlobValidator.is(identityKeys)) { throw new ServerError('invalid_identity_keys_blob'); } // 3. Create content olm session. (The notif session is not required for auth // and will be created later in `processSuccessfulLogin(...)`.) const pickledContentOlmSessionPromise = createOlmSession( initialContentEncryptedMessage, 'content', identityKeys.primaryIdentityPublicKeys.curve25519, ); // 4. Create account with call to `processOLMAccountCreation(...)` // if username does not correspond to an existing user. If we successfully // create a new account, we set `cookieHasBeenSet` to true to avoid // creating a new cookie again in `processSuccessfulLogin`. const signedIdentityKeysBlob: SignedIdentityKeysBlob = { payload: inboundKeysForUser.payload, signature: inboundKeysForUser.payloadSignature, }; const olmAccountCreationPromise = (async () => { if (existingUsername) { return; } const olmAccountCreationRequest = { userID, username: username, walletAddress: inboundKeysForUser.walletAddress, signedIdentityKeysBlob, calendarQuery, deviceTokenUpdateRequest, platformDetails, }; await processOLMAccountCreation(viewer, olmAccountCreationRequest); })(); const [pickledContentOlmSession] = await Promise.all([ pickledContentOlmSessionPromise, olmAccountCreationPromise, ]); // 5. Complete login with call to `processSuccessfulLogin(...)`. return await processSuccessfulLogin({ viewer, input: request, userID, calendarQuery, signedIdentityKeysBlob, initialNotificationsEncryptedMessage, pickledContentOlmSession, cookieHasBeenSet: !existingUsername, }); } export const updatePasswordRequestInputValidator: TInterface = tShape({ code: t.String, password: tPassword, watchedIDs: t.list(tID), calendarQuery: t.maybe(entryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, }); async function oldPasswordUpdateResponder( viewer: Viewer, request: UpdatePasswordRequest, ): Promise { if (request.calendarQuery) { request.calendarQuery = normalizeCalendarQuery(request.calendarQuery); } return await updatePassword(viewer, request); } export const updateUserSettingsInputValidator: TInterface = tShape({ name: t.irreducible( userSettingsTypes.DEFAULT_NOTIFICATIONS, x => x === userSettingsTypes.DEFAULT_NOTIFICATIONS, ), data: t.enums.of(notificationTypeValues), }); async function updateUserSettingsResponder( viewer: Viewer, request: UpdateUserSettingsRequest, ): Promise { await updateUserSettings(viewer, request); } export const policyAcknowledgmentRequestInputValidator: TInterface = tShape({ policy: t.maybe(t.enums.of(policies)), }); async function policyAcknowledgmentResponder( viewer: Viewer, request: PolicyAcknowledgmentRequest, ): Promise { await viewerAcknowledgmentUpdater(viewer, request.policy); } export const updateUserAvatarResponseValidator: TInterface = tShape({ updates: createUpdatesResultValidator, }); export const updateUserAvatarResponderValidator: TUnion< ?ClientAvatar | UpdateUserAvatarResponse, > = t.union([ t.maybe(clientAvatarValidator), updateUserAvatarResponseValidator, ]); async function updateUserAvatarResponder( viewer: Viewer, request: UpdateUserAvatarRequest, ): Promise { return await updateUserAvatar(viewer, request); } export const claimUsernameResponseValidator: TInterface = tShape({ message: t.String, signature: t.String, }); async function claimUsernameResponder( viewer: Viewer, ): Promise { const [username, accountInfo] = await Promise.all([ fetchUsername(viewer.userID), fetchOlmAccount('content'), ]); if (!username) { throw new ServerError('invalid_credentials'); } const issuedAt = new Date().toISOString(); const reservedUsernameMessage: ReservedUsernameMessage = { statement: 'This user is the owner of the following username and user ID', payload: { username, userID: viewer.userID, }, issuedAt, }; const message = JSON.stringify(reservedUsernameMessage); const signature = accountInfo.account.sign(message); return { message, signature }; } export { userSubscriptionUpdateResponder, passwordUpdateResponder, sendVerificationEmailResponder, sendPasswordResetEmailResponder, logOutResponder, accountDeletionResponder, accountCreationResponder, logInResponder, siweAuthResponder, oldPasswordUpdateResponder, updateUserSettingsResponder, policyAcknowledgmentResponder, updateUserAvatarResponder, claimUsernameResponder, keyserverAuthResponder, }; diff --git a/keyserver/src/scripts/merge-users.js b/keyserver/src/scripts/merge-users.js deleted file mode 100644 index aa5a61352..000000000 --- a/keyserver/src/scripts/merge-users.js +++ /dev/null @@ -1,195 +0,0 @@ -// @flow - -import type { ServerThreadInfo } from 'lib/types/thread-types.js'; -import { updateTypes } from 'lib/types/update-types-enum.js'; -import { type UpdateData } from 'lib/types/update-types.js'; - -import { endScript } from './utils.js'; -import { createUpdates } from '../creators/update-creator.js'; -import { dbQuery, SQL } from '../database/database.js'; -import type { SQLStatementType } from '../database/types.js'; -import { deleteAccount } from '../deleters/account-deleters.js'; -import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; -import { createScriptViewer } from '../session/scripts.js'; -import { - changeRole, - commitMembershipChangeset, - type MembershipRow, -} from '../updaters/thread-permission-updaters.js'; -import RelationshipChangeset from '../utils/relationship-changeset.js'; - -async function main() { - try { - await mergeUsers('7147', '15972', { username: true, password: true }); - endScript(); - } catch (e) { - endScript(); - console.warn(e); - } -} - -type ReplaceUserInfo = Partial<{ - +username: boolean, - +password: boolean, -}>; -async function mergeUsers( - fromUserID: string, - toUserID: string, - replaceUserInfo?: ReplaceUserInfo, -) { - let updateUserRowQuery: ?SQLStatementType = null; - let updateDatas: UpdateData[] = []; - if (replaceUserInfo) { - const replaceUserResult = await replaceUser( - fromUserID, - toUserID, - replaceUserInfo, - ); - ({ sql: updateUserRowQuery, updateDatas } = replaceUserResult); - } - - const usersGettingUpdate = new Set(); - const usersNeedingUpdate = new Set(); - const needUserInfoUpdate = replaceUserInfo && replaceUserInfo.username; - const setGettingUpdate = (threadInfo: ServerThreadInfo) => { - if (!needUserInfoUpdate) { - return; - } - for (const { id } of threadInfo.members) { - usersGettingUpdate.add(id); - usersNeedingUpdate.delete(id); - } - }; - const setNeedingUpdate = (threadInfo: ServerThreadInfo) => { - if (!needUserInfoUpdate) { - return; - } - for (const { id } of threadInfo.members) { - if (!usersGettingUpdate.has(id)) { - usersNeedingUpdate.add(id); - } - } - }; - - const newThreadRolePairs = []; - const { threadInfos } = await fetchServerThreadInfos(); - for (const threadID in threadInfos) { - const threadInfo = threadInfos[threadID]; - const fromUserExistingMember = threadInfo.members.find( - memberInfo => memberInfo.id === fromUserID, - ); - if (!fromUserExistingMember) { - setNeedingUpdate(threadInfo); - continue; - } - const { role } = fromUserExistingMember; - if (!role) { - // Only transfer explicit memberships - setNeedingUpdate(threadInfo); - continue; - } - const toUserExistingMember = threadInfo.members.find( - memberInfo => memberInfo.id === toUserID, - ); - if (!toUserExistingMember || !toUserExistingMember.role) { - setGettingUpdate(threadInfo); - newThreadRolePairs.push([threadID, role]); - } else { - setNeedingUpdate(threadInfo); - } - } - - const fromViewer = createScriptViewer(fromUserID); - await deleteAccount(fromViewer); - - if (updateUserRowQuery) { - await dbQuery(updateUserRowQuery); - } - - const time = Date.now(); - for (const userID of usersNeedingUpdate) { - updateDatas.push({ - type: updateTypes.UPDATE_USER, - userID, - time, - updatedUserID: toUserID, - }); - } - await createUpdates(updateDatas); - - const changesets = await Promise.all( - newThreadRolePairs.map(([threadID, role]) => - changeRole(threadID, [toUserID], role), - ), - ); - const membershipRows: Array = []; - const relationshipChangeset = new RelationshipChangeset(); - for (const currentChangeset of changesets) { - const { - membershipRows: currentMembershipRows, - relationshipChangeset: currentRelationshipChangeset, - } = currentChangeset; - membershipRows.push(...currentMembershipRows); - relationshipChangeset.addAll(currentRelationshipChangeset); - } - if (membershipRows.length > 0 || relationshipChangeset.getRowCount() > 0) { - const toViewer = createScriptViewer(toUserID); - const changeset = { membershipRows, relationshipChangeset }; - await commitMembershipChangeset(toViewer, changeset); - } -} - -type ReplaceUserResult = { - sql: ?SQLStatementType, - updateDatas: UpdateData[], -}; -async function replaceUser( - fromUserID: string, - toUserID: string, - replaceUserInfo: ReplaceUserInfo, -): Promise { - if (Object.keys(replaceUserInfo).length === 0) { - return { - sql: null, - updateDatas: [], - }; - } - - const fromUserQuery = SQL` - SELECT username, hash - FROM users - WHERE id = ${fromUserID} - `; - const [fromUserResult] = await dbQuery(fromUserQuery); - const [firstResult] = fromUserResult; - if (!firstResult) { - throw new Error(`couldn't fetch fromUserID ${fromUserID}`); - } - - const changedFields: { [string]: string } = {}; - if (replaceUserInfo.username) { - changedFields.username = firstResult.username; - } - if (replaceUserInfo.password) { - changedFields.hash = firstResult.hash; - } - - const updateUserRowQuery = SQL` - UPDATE users SET ${changedFields} WHERE id = ${toUserID} - `; - - const updateDatas: UpdateData[] = []; - if (replaceUserInfo.username) { - updateDatas.push({ - type: updateTypes.UPDATE_CURRENT_USER, - userID: toUserID, - time: Date.now(), - }); - } - return { - sql: updateUserRowQuery, - updateDatas, - }; -} - -void main(); diff --git a/keyserver/src/scripts/rename-user.js b/keyserver/src/scripts/rename-user.js deleted file mode 100644 index 4cb00be07..000000000 --- a/keyserver/src/scripts/rename-user.js +++ /dev/null @@ -1,40 +0,0 @@ -// @flow - -import { updateTypes } from 'lib/types/update-types-enum.js'; - -import { main } from './utils.js'; -import { createUpdates } from '../creators/update-creator.js'; -import { dbQuery, SQL } from '../database/database.js'; -import { fetchKnownUserInfos } from '../fetchers/user-fetchers.js'; -import { createScriptViewer } from '../session/scripts.js'; - -const userID = '5'; -const newUsername = 'commbot'; - -async function renameUser() { - const [adjacentUsers] = await Promise.all([ - fetchKnownUserInfos(createScriptViewer(userID)), - dbQuery( - SQL`UPDATE users SET username = ${newUsername} WHERE id = ${userID}`, - ), - ]); - - const updateDatas = []; - const time = Date.now(); - updateDatas.push({ - type: updateTypes.UPDATE_CURRENT_USER, - userID, - time, - }); - for (const adjacentUserID in adjacentUsers) { - updateDatas.push({ - type: updateTypes.UPDATE_USER, - userID: adjacentUserID, - time, - updatedUserID: userID, - }); - } - await createUpdates(updateDatas); -} - -main([renameUser]); diff --git a/keyserver/src/updaters/account-updaters.js b/keyserver/src/updaters/account-updaters.js index a9b428f3d..195f17200 100644 --- a/keyserver/src/updaters/account-updaters.js +++ b/keyserver/src/updaters/account-updaters.js @@ -1,265 +1,253 @@ // @flow import invariant from 'invariant'; import bcrypt from 'twin-bcrypt'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import type { ResetPasswordRequest, UpdatePasswordRequest, UpdateUserSettingsRequest, ServerLogInResponse, } from 'lib/types/account-types.js'; import type { ClientAvatar, UpdateUserAvatarRequest, UpdateUserAvatarResponse, } from 'lib/types/avatar-types.js'; import { updateTypes } from 'lib/types/update-types-enum.js'; import type { CreateUpdatesResult, UpdateData, } from 'lib/types/update-types.js'; import type { PasswordUpdate, UserInfo, UserInfos, } from 'lib/types/user-types.js'; import { ServerError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { getUploadURL, makeUploadURI } from '../fetchers/upload-fetchers.js'; import { fetchKnownUserInfos } from '../fetchers/user-fetchers.js'; import type { Viewer } from '../session/viewer.js'; -async function accountUpdater( +async function passwordUpdater( viewer: Viewer, update: PasswordUpdate, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const newPassword = update.updatedFields.password; if (!newPassword) { // If it's an old client it may have given us an email, // but we don't store those anymore return; } const verifyQuery = SQL` SELECT username, hash FROM users WHERE id = ${viewer.userID} `; const [verifyResult] = await dbQuery(verifyQuery); if (verifyResult.length === 0) { throw new ServerError('internal_error'); } const verifyRow = verifyResult[0]; if (!bcrypt.compareSync(update.currentPassword, verifyRow.hash)) { throw new ServerError('invalid_credentials'); } const changedFields = { hash: bcrypt.hashSync(newPassword) }; const saveQuery = SQL` UPDATE users SET ${changedFields} WHERE id = ${viewer.userID} `; await dbQuery(saveQuery); - - const updateDatas = [ - { - type: updateTypes.UPDATE_CURRENT_USER, - userID: viewer.userID, - time: Date.now(), - }, - ]; - await createUpdates(updateDatas, { - viewer, - updatesForCurrentSession: 'broadcast', - }); } // eslint-disable-next-line no-unused-vars async function checkAndSendVerificationEmail(viewer: Viewer): Promise { // We don't want to crash old clients that call this, // but we have nothing we can do because we no longer store email addresses } async function checkAndSendPasswordResetEmail( // eslint-disable-next-line no-unused-vars request: ResetPasswordRequest, ): Promise { // We don't want to crash old clients that call this, // but we have nothing we can do because we no longer store email addresses } /* eslint-disable no-unused-vars */ async function updatePassword( viewer: Viewer, request: UpdatePasswordRequest, ): Promise { /* eslint-enable no-unused-vars */ // We have no way to handle this request anymore throw new ServerError('deprecated'); } async function updateUserSettings( viewer: Viewer, request: UpdateUserSettingsRequest, ) { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const createOrUpdateSettingsQuery = SQL` INSERT INTO settings (user, name, data) VALUES ${[[viewer.id, request.name, request.data]]} ON DUPLICATE KEY UPDATE data = VALUE(data) `; await dbQuery(createOrUpdateSettingsQuery); } async function updateUserAvatar( viewer: Viewer, request: UpdateUserAvatarRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const newAvatarValue = request.type === 'remove' ? null : JSON.stringify(request); const mediaID = request.type === 'image' || request.type === 'encrypted_image' ? request.uploadID : null; const query = SQL` START TRANSACTION; UPDATE uploads SET container = NULL WHERE uploader = ${viewer.userID} AND container = ${viewer.userID} AND ( ${mediaID} IS NULL OR EXISTS ( SELECT 1 FROM uploads WHERE id = ${mediaID} AND uploader = ${viewer.userID} AND container IS NULL AND thread IS NULL ) ); UPDATE uploads SET container = ${viewer.userID} WHERE id = ${mediaID} AND uploader = ${viewer.userID} AND container IS NULL AND thread IS NULL; UPDATE users SET avatar = ${newAvatarValue} WHERE id = ${viewer.userID} AND ( ${mediaID} IS NULL OR EXISTS ( SELECT 1 FROM uploads WHERE id = ${mediaID} AND uploader = ${viewer.userID} AND container = ${viewer.userID} AND thread IS NULL ) ); COMMIT; SELECT id AS upload_id, secret AS upload_secret, extra AS upload_extra FROM uploads WHERE id = ${mediaID} AND uploader = ${viewer.userID} AND container = ${viewer.userID}; `; const [resultSet] = await dbQuery(query, { multipleStatements: true }); const selectResult = resultSet.pop(); const knownUserInfos: UserInfos = await fetchKnownUserInfos(viewer); const updates: CreateUpdatesResult = await createUserAvatarUpdates( viewer, knownUserInfos, ); if (hasMinCodeVersion(viewer.platformDetails, { native: 215 })) { const updateUserAvatarResponse: UpdateUserAvatarResponse = { updates, }; return updateUserAvatarResponse; } if (request.type === 'remove') { return null; } else if (request.type !== 'image' && request.type !== 'encrypted_image') { return request; } else { const [{ upload_id, upload_secret, upload_extra }] = selectResult; const uploadID = upload_id.toString(); invariant( uploadID === request.uploadID, 'uploadID of upload should match uploadID of UpdateUserAvatarRequest', ); if (request.type === 'encrypted_image') { const uploadExtra = JSON.parse(upload_extra); return { type: 'encrypted_image', blobURI: makeUploadURI(uploadExtra.blobHash, uploadID, upload_secret), encryptionKey: uploadExtra.encryptionKey, thumbHash: uploadExtra.thumbHash, }; } return { type: 'image', uri: getUploadURL(uploadID, upload_secret), }; } } async function createUserAvatarUpdates( viewer: Viewer, knownUserInfos: UserInfos, ): Promise { const time = Date.now(); const userUpdates: $ReadOnlyArray = values(knownUserInfos).map( (user: UserInfo): UpdateData => ({ type: updateTypes.UPDATE_USER, userID: user.id, time, updatedUserID: viewer.userID, }), ); const currentUserUpdate: UpdateData = { type: updateTypes.UPDATE_CURRENT_USER, userID: viewer.userID, time, }; return await createUpdates([...userUpdates, currentUserUpdate], { viewer, updatesForCurrentSession: 'return', }); } export { - accountUpdater, + passwordUpdater, checkAndSendVerificationEmail, checkAndSendPasswordResetEmail, updateUserSettings, updatePassword, updateUserAvatar, };