diff --git a/keyserver/src/endpoints.js b/keyserver/src/endpoints.js index 84039c45a..e96d66784 100644 --- a/keyserver/src/endpoints.js +++ b/keyserver/src/endpoints.js @@ -1,244 +1,242 @@ // @flow -import { policyTypes } from 'lib/facts/policies.js'; +import { baseLegalPolicies } from 'lib/facts/policies.js'; import type { Endpoint } from 'lib/types/endpoints'; import { updateActivityResponder, threadSetUnreadStatusResponder, } from './responders/activity-responders'; import { deviceTokenUpdateResponder } from './responders/device-responders'; import { entryFetchResponder, entryRevisionFetchResponder, entryCreationResponder, entryUpdateResponder, entryDeletionResponder, entryRestorationResponder, calendarQueryUpdateResponder, } from './responders/entry-responders'; import type { JSONResponder } from './responders/handlers'; import { getSessionPublicKeysResponder } from './responders/keys-responders'; import { messageReportCreationResponder } from './responders/message-report-responder'; import { textMessageCreationResponder, messageFetchResponder, multimediaMessageCreationResponder, reactionMessageCreationResponder, } from './responders/message-responders'; import { updateRelationshipsResponder } from './responders/relationship-responders'; import { reportCreationResponder, reportMultiCreationResponder, errorReportFetchInfosResponder, } from './responders/report-responders'; import { userSearchResponder } from './responders/search-responders'; import { siweNonceResponder } from './responders/siwe-nonce-responders'; import { threadDeletionResponder, roleUpdateResponder, memberRemovalResponder, threadLeaveResponder, threadUpdateResponder, threadCreationResponder, threadJoinResponder, } from './responders/thread-responders'; import { userSubscriptionUpdateResponder, passwordUpdateResponder, sendVerificationEmailResponder, sendPasswordResetEmailResponder, logOutResponder, accountDeletionResponder, accountCreationResponder, logInResponder, siweAuthResponder, oldPasswordUpdateResponder, updateUserSettingsResponder, policyAcknowledgmentResponder, } from './responders/user-responders'; import { codeVerificationResponder } from './responders/verification-responders'; import { uploadDeletionResponder } from './uploads/uploads'; -const baseLegalPolicies = [policyTypes.tosAndPrivacyPolicy]; - const jsonEndpoints: { [id: Endpoint]: JSONResponder } = { create_account: { responder: accountCreationResponder, requiredPolicies: [], }, create_entry: { responder: entryCreationResponder, requiredPolicies: baseLegalPolicies, }, create_error_report: { responder: reportCreationResponder, requiredPolicies: [], }, create_message_report: { responder: messageReportCreationResponder, requiredPolicies: baseLegalPolicies, }, create_multimedia_message: { responder: multimediaMessageCreationResponder, requiredPolicies: baseLegalPolicies, }, create_reaction_message: { responder: reactionMessageCreationResponder, requiredPolicies: baseLegalPolicies, }, create_report: { responder: reportCreationResponder, requiredPolicies: [], }, create_reports: { responder: reportMultiCreationResponder, requiredPolicies: [], }, create_text_message: { responder: textMessageCreationResponder, requiredPolicies: baseLegalPolicies, }, create_thread: { responder: threadCreationResponder, requiredPolicies: baseLegalPolicies, }, delete_account: { responder: accountDeletionResponder, requiredPolicies: [], }, delete_entry: { responder: entryDeletionResponder, requiredPolicies: baseLegalPolicies, }, delete_thread: { responder: threadDeletionResponder, requiredPolicies: baseLegalPolicies, }, delete_upload: { responder: uploadDeletionResponder, requiredPolicies: baseLegalPolicies, }, fetch_entries: { responder: entryFetchResponder, requiredPolicies: baseLegalPolicies, }, fetch_entry_revisions: { responder: entryRevisionFetchResponder, requiredPolicies: baseLegalPolicies, }, fetch_error_report_infos: { responder: errorReportFetchInfosResponder, requiredPolicies: baseLegalPolicies, }, fetch_messages: { responder: messageFetchResponder, requiredPolicies: baseLegalPolicies, }, get_session_public_keys: { responder: getSessionPublicKeysResponder, requiredPolicies: baseLegalPolicies, }, join_thread: { responder: threadJoinResponder, requiredPolicies: baseLegalPolicies, }, leave_thread: { responder: threadLeaveResponder, requiredPolicies: baseLegalPolicies, }, log_in: { responder: logInResponder, requiredPolicies: [], }, log_out: { responder: logOutResponder, requiredPolicies: [], }, policy_acknowledgment: { responder: policyAcknowledgmentResponder, requiredPolicies: [], }, remove_members: { responder: memberRemovalResponder, requiredPolicies: baseLegalPolicies, }, restore_entry: { responder: entryRestorationResponder, requiredPolicies: baseLegalPolicies, }, search_users: { responder: userSearchResponder, requiredPolicies: baseLegalPolicies, }, send_password_reset_email: { responder: sendPasswordResetEmailResponder, requiredPolicies: [], }, send_verification_email: { responder: sendVerificationEmailResponder, requiredPolicies: [], }, set_thread_unread_status: { responder: threadSetUnreadStatusResponder, requiredPolicies: baseLegalPolicies, }, update_account: { responder: passwordUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_activity: { responder: updateActivityResponder, requiredPolicies: baseLegalPolicies, }, update_calendar_query: { responder: calendarQueryUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_user_settings: { responder: updateUserSettingsResponder, requiredPolicies: baseLegalPolicies, }, update_device_token: { responder: deviceTokenUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_entry: { responder: entryUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_password: { responder: oldPasswordUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_relationships: { responder: updateRelationshipsResponder, requiredPolicies: baseLegalPolicies, }, update_role: { responder: roleUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_thread: { responder: threadUpdateResponder, requiredPolicies: baseLegalPolicies, }, update_user_subscription: { responder: userSubscriptionUpdateResponder, requiredPolicies: baseLegalPolicies, }, verify_code: { responder: codeVerificationResponder, requiredPolicies: baseLegalPolicies, }, siwe_nonce: { responder: siweNonceResponder, requiredPolicies: [], }, siwe_auth: { responder: siweAuthResponder, requiredPolicies: [], }, }; export { jsonEndpoints }; diff --git a/keyserver/src/fetchers/policy-acknowledgment-fetchers.js b/keyserver/src/fetchers/policy-acknowledgment-fetchers.js index 86009658f..50840c6ba 100644 --- a/keyserver/src/fetchers/policy-acknowledgment-fetchers.js +++ b/keyserver/src/fetchers/policy-acknowledgment-fetchers.js @@ -1,39 +1,38 @@ // @flow import type { PolicyType } from 'lib/facts/policies.js'; import { type UserPolicyConfirmationType } from 'lib/types/policy-types.js'; import { dbQuery, SQL } from '../database/database.js'; -import { Viewer } from '../session/viewer.js'; async function fetchPolicyAcknowledgments( - viewer: Viewer, + userID: string, policies: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const query = SQL` SELECT policy, confirmed FROM policy_acknowledgments - WHERE user=${viewer.id} + WHERE user=${userID} AND policy IN (${policies}) `; const [data] = await dbQuery(query); return data; } async function fetchNotAcknowledgedPolicies( - viewer: Viewer, + userID: string, policies: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const viewerAcknowledgments = await fetchPolicyAcknowledgments( - viewer, + userID, policies, ); return policies.filter(policy => { const policyAcknowledgment = viewerAcknowledgments.find( viewerAcknowledgment => viewerAcknowledgment.policy === policy, ); return !policyAcknowledgment?.confirmed; }); } export { fetchPolicyAcknowledgments, fetchNotAcknowledgedPolicies }; diff --git a/keyserver/src/responders/user-responders.js b/keyserver/src/responders/user-responders.js index 70ab17034..b14564ac2 100644 --- a/keyserver/src/responders/user-responders.js +++ b/keyserver/src/responders/user-responders.js @@ -1,503 +1,529 @@ // @flow import invariant from 'invariant'; import { ErrorTypes, SiweMessage } from 'siwe'; import t from 'tcomb'; import bcrypt from 'twin-bcrypt'; -import { policies } from 'lib/facts/policies.js'; +import { baseLegalPolicies, policies } from 'lib/facts/policies.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils'; import type { ResetPasswordRequest, LogOutResponse, DeleteAccountRequest, RegisterResponse, RegisterRequest, LogInResponse, LogInRequest, UpdatePasswordRequest, UpdateUserSettingsRequest, PolicyAcknowledgmentRequest, } from 'lib/types/account-types'; import { userSettingsTypes, notificationTypeValues, logInActionSources, } from 'lib/types/account-types'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import { defaultNumberPerThread } from 'lib/types/message-types'; import type { SIWEAuthRequest, SIWEMessage, SIWESocialProof, } from 'lib/types/siwe-types.js'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResponse, } from 'lib/types/subscription-types'; import type { PasswordUpdate } from 'lib/types/user-types'; import { ServerError } from 'lib/utils/errors'; import { values } from 'lib/utils/objects'; import { promiseAll } from 'lib/utils/promises'; import { getPublicKeyFromSIWEStatement, isValidSIWEMessage, isValidSIWEStatementWithPublicKey, } from 'lib/utils/siwe-utils.js'; import { tShape, tPlatformDetails, tPassword, tEmail, tOldValidUsername, } from 'lib/utils/validation-utils'; import { createAccount, processSIWEAccountCreation, } from '../creators/account-creator'; import { dbQuery, SQL } from '../database/database'; import { deleteAccount } from '../deleters/account-deleters'; import { deleteCookie } from '../deleters/cookie-deleters'; import { checkAndInvalidateSIWENonceEntry } from '../deleters/siwe-nonce-deleters.js'; import { fetchEntryInfos } from '../fetchers/entry-fetchers'; import { fetchMessageInfos } from '../fetchers/message-fetchers'; +import { fetchNotAcknowledgedPolicies } from '../fetchers/policy-acknowledgment-fetchers.js'; import { fetchThreadInfos } from '../fetchers/thread-fetchers'; import { fetchKnownUserInfos, fetchLoggedInUserInfo, fetchUserIDForEthereumAddress, } from '../fetchers/user-fetchers'; import { createNewAnonymousCookie, createNewUserCookie, setNewSession, } from '../session/cookies'; import type { Viewer } from '../session/viewer'; import { accountUpdater, checkAndSendVerificationEmail, checkAndSendPasswordResetEmail, updatePassword, updateUserSettings, } from '../updaters/account-updaters'; import { userSubscriptionUpdater } from '../updaters/user-subscription-updaters'; import { viewerAcknowledgmentUpdater } from '../updaters/viewer-acknowledgment-updater.js'; import { validateInput } from '../utils/validation-utils'; import { entryQueryInputValidator, newEntryQueryInputValidator, normalizeCalendarQuery, verifyCalendarQueryThreadIDs, } from './entry-responders'; const subscriptionUpdateRequestInputValidator = tShape({ threadID: t.String, updatedFields: tShape({ pushNotifs: t.maybe(t.Boolean), home: t.maybe(t.Boolean), }), }); async function userSubscriptionUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: SubscriptionUpdateRequest = input; await validateInput(viewer, subscriptionUpdateRequestInputValidator, request); const threadSubscription = await userSubscriptionUpdater(viewer, request); return { threadSubscription }; } const accountUpdateInputValidator = tShape({ updatedFields: tShape({ email: t.maybe(tEmail), password: t.maybe(tPassword), }), currentPassword: tPassword, }); async function passwordUpdateResponder( viewer: Viewer, input: any, ): Promise { const request: PasswordUpdate = input; await validateInput(viewer, accountUpdateInputValidator, request); await accountUpdater(viewer, request); } async function sendVerificationEmailResponder(viewer: Viewer): Promise { await validateInput(viewer, null, null); await checkAndSendVerificationEmail(viewer); } const resetPasswordRequestInputValidator = tShape({ usernameOrEmail: t.union([tEmail, tOldValidUsername]), }); async function sendPasswordResetEmailResponder( viewer: Viewer, input: any, ): Promise { const request: ResetPasswordRequest = input; await validateInput(viewer, resetPasswordRequestInputValidator, request); await checkAndSendPasswordResetEmail(request); } async function logOutResponder(viewer: Viewer): Promise { await validateInput(viewer, null, null); if (viewer.loggedIn) { const [anonymousViewerData] = await Promise.all([ createNewAnonymousCookie({ platformDetails: viewer.platformDetails, deviceToken: viewer.deviceToken, }), deleteCookie(viewer.cookieID), ]); viewer.setNewCookie(anonymousViewerData); } return { currentUserInfo: { id: viewer.id, anonymous: true, }, }; } const deleteAccountRequestInputValidator = tShape({ password: t.maybe(tPassword), }); async function accountDeletionResponder( viewer: Viewer, input: any, ): Promise { const request: DeleteAccountRequest = input; await validateInput(viewer, deleteAccountRequestInputValidator, request); const result = await deleteAccount(viewer, request); invariant(result, 'deleteAccount should return result if handed request'); return result; } const deviceTokenUpdateRequestInputValidator = tShape({ deviceType: t.maybe(t.enums.of(['ios', 'android'])), deviceToken: t.String, }); const registerRequestInputValidator = tShape({ username: t.String, email: t.maybe(tEmail), password: tPassword, calendarQuery: t.maybe(newEntryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, }); async function accountCreationResponder( viewer: Viewer, input: any, ): Promise { const request: RegisterRequest = input; await validateInput(viewer, registerRequestInputValidator, request); return await createAccount(viewer, request); } async function processSuccessfulLogin( viewer: Viewer, input: any, userID: string, calendarQuery: ?CalendarQuery, primaryIdentityPublicKey?: ?string, socialProof?: ?SIWESocialProof, ): Promise { const request: LogInRequest = input; const newServerTime = Date.now(); const deviceToken = request.deviceTokenUpdateRequest ? request.deviceTokenUpdateRequest.deviceToken : viewer.deviceToken; - const [userViewerData] = await Promise.all([ + const [userViewerData, notAcknowledgedPolicies] = await Promise.all([ createNewUserCookie(userID, { platformDetails: request.platformDetails, deviceToken, primaryIdentityPublicKey, socialProof, }), + fetchNotAcknowledgedPolicies(userID, baseLegalPolicies), deleteCookie(viewer.cookieID), ]); viewer.setNewCookie(userViewerData); + + if ( + notAcknowledgedPolicies.length && + hasMinCodeVersion(viewer.platformDetails, 1000) + ) { + 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 threadCursors = {}; for (const watchedThreadID of request.watchedIDs) { threadCursors[watchedThreadID] = null; } const messageSelectionCriteria = { threadCursors, joinedThreads: true }; const [ threadsResult, messagesResult, entriesResult, userInfos, currentUserInfo, ] = await Promise.all([ fetchThreadInfos(viewer), fetchMessageInfos(viewer, messageSelectionCriteria, defaultNumberPerThread), calendarQuery ? fetchEntryInfos(viewer, [calendarQuery]) : undefined, fetchKnownUserInfos(viewer), fetchLoggedInUserInfo(viewer), ]); const rawEntryInfos = entriesResult ? entriesResult.rawEntryInfos : null; const response: LogInResponse = { currentUserInfo, rawMessageInfos: messagesResult.rawMessageInfos, truncationStatuses: messagesResult.truncationStatuses, serverTime: newServerTime, userInfos: values(userInfos), cookieChange: { threadInfos: threadsResult.threadInfos, userInfos: [], }, }; if (rawEntryInfos) { - response.rawEntryInfos = rawEntryInfos; + return { + ...response, + rawEntryInfos, + }; } return response; } const logInRequestInputValidator = tShape({ username: t.maybe(t.String), usernameOrEmail: t.maybe(t.union([tEmail, tOldValidUsername])), password: tPassword, watchedIDs: t.list(t.String), calendarQuery: t.maybe(entryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, source: t.maybe(t.enums.of(values(logInActionSources))), }); async function logInResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, logInRequestInputValidator, input); const request: LogInRequest = input; const calendarQuery = request.calendarQuery ? normalizeCalendarQuery(request.calendarQuery) : null; const promises = {}; if (calendarQuery) { promises.verifyCalendarQueryThreadIDs = verifyCalendarQueryThreadIDs( calendarQuery, ); } const username = request.username ?? request.usernameOrEmail; if (!username) { if (hasMinCodeVersion(viewer.platformDetails, 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}) `; promises.userQuery = dbQuery(userQuery); const { userQuery: [userResult], } = await promiseAll(promises); if (userResult.length === 0) { if (hasMinCodeVersion(viewer.platformDetails, 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, id, calendarQuery); } const siweAuthRequestInputValidator = tShape({ signature: t.String, message: t.String, calendarQuery: entryQueryInputValidator, deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, watchedIDs: t.list(t.String), }); async function siweAuthResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, siweAuthRequestInputValidator, input); const request: SIWEAuthRequest = input; const { message, signature, deviceTokenUpdateRequest, platformDetails, } = 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. 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'); } // 3. 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'); } } // 4. Pull `primaryIdentityPublicKey` out from SIWEMessage `statement` // if it was included. We expect it to be included for native clients, // and we expect it to be EXCLUDED for web clients. const { statement } = siweMessage; const primaryIdentityPublicKey = statement && isValidSIWEStatementWithPublicKey(statement) ? getPublicKeyFromSIWEStatement(statement) : null; // 5. Construct `SIWESocialProof` object with the stringified // SIWEMessage and the corresponding signature. const socialProof: SIWESocialProof = { siweMessage: siweMessage.toMessage(), siweMessageSignature: signature, }; // 6. Create account with call to `processSIWEAccountCreation(...)` // if address does not correspond to an existing user. let userID = await fetchUserIDForEthereumAddress(siweMessage.address); if (!userID) { const siweAccountCreationRequest = { address: siweMessage.address, calendarQuery, deviceTokenUpdateRequest, platformDetails, primaryIdentityPublicKey, socialProof, }; userID = await processSIWEAccountCreation( viewer, siweAccountCreationRequest, ); } // 7. Complete login with call to `processSuccessfulLogin(...)`. return await processSuccessfulLogin( viewer, input, userID, calendarQuery, primaryIdentityPublicKey, socialProof, ); } const updatePasswordRequestInputValidator = tShape({ code: t.String, password: tPassword, watchedIDs: t.list(t.String), calendarQuery: t.maybe(entryQueryInputValidator), deviceTokenUpdateRequest: t.maybe(deviceTokenUpdateRequestInputValidator), platformDetails: tPlatformDetails, }); async function oldPasswordUpdateResponder( viewer: Viewer, input: any, ): Promise { await validateInput(viewer, updatePasswordRequestInputValidator, input); const request: UpdatePasswordRequest = input; if (request.calendarQuery) { request.calendarQuery = normalizeCalendarQuery(request.calendarQuery); } return await updatePassword(viewer, request); } const updateUserSettingsInputValidator = tShape({ name: t.irreducible( userSettingsTypes.DEFAULT_NOTIFICATIONS, x => x === userSettingsTypes.DEFAULT_NOTIFICATIONS, ), data: t.enums.of(notificationTypeValues), }); async function updateUserSettingsResponder( viewer: Viewer, input: any, ): Promise { const request: UpdateUserSettingsRequest = input; await validateInput(viewer, updateUserSettingsInputValidator, request); return await updateUserSettings(viewer, request); } const policyAcknowledgmentRequestInputValidator = tShape({ policy: t.maybe(t.enums.of(policies)), }); async function policyAcknowledgmentResponder( viewer: Viewer, input: any, ): Promise { const request: PolicyAcknowledgmentRequest = input; await validateInput( viewer, policyAcknowledgmentRequestInputValidator, request, ); await viewerAcknowledgmentUpdater(viewer, request.policy); } export { userSubscriptionUpdateResponder, passwordUpdateResponder, sendVerificationEmailResponder, sendPasswordResetEmailResponder, logOutResponder, accountDeletionResponder, accountCreationResponder, logInResponder, siweAuthResponder, oldPasswordUpdateResponder, updateUserSettingsResponder, policyAcknowledgmentResponder, }; diff --git a/keyserver/src/utils/validation-utils.js b/keyserver/src/utils/validation-utils.js index 22b574486..ba9b152eb 100644 --- a/keyserver/src/utils/validation-utils.js +++ b/keyserver/src/utils/validation-utils.js @@ -1,187 +1,187 @@ // @flow import type { PolicyType } from 'lib/facts/policies.js'; import { hasMinCodeVersion } from 'lib/shared/version-utils.js'; import { ServerError } from 'lib/utils/errors'; import { tCookie, tPassword, tPlatform, tPlatformDetails, } from 'lib/utils/validation-utils'; import { fetchNotAcknowledgedPolicies } from '../fetchers/policy-acknowledgment-fetchers.js'; import { verifyClientSupported } from '../session/version'; import type { Viewer } from '../session/viewer'; async function validateInput(viewer: Viewer, inputValidator: *, input: *) { if (!viewer.isSocket) { await checkClientSupported(viewer, inputValidator, input); } checkInputValidator(inputValidator, input); } function checkInputValidator(inputValidator: *, input: *) { if (!inputValidator || inputValidator.is(input)) { return; } const error = new ServerError('invalid_parameters'); error.sanitizedInput = input ? sanitizeInput(inputValidator, input) : null; throw error; } async function checkClientSupported( viewer: Viewer, inputValidator: *, input: *, ) { let platformDetails; if (inputValidator) { platformDetails = findFirstInputMatchingValidator( inputValidator, tPlatformDetails, input, ); } if (!platformDetails && inputValidator) { const platform = findFirstInputMatchingValidator( inputValidator, tPlatform, input, ); if (platform) { platformDetails = { platform }; } } if (!platformDetails) { ({ platformDetails } = viewer); } await verifyClientSupported(viewer, platformDetails); } const redactedString = '********'; const redactedTypes = [tPassword, tCookie]; function sanitizeInput(inputValidator: *, input: *) { if (!inputValidator) { return input; } if (redactedTypes.includes(inputValidator) && typeof input === 'string') { return redactedString; } if ( inputValidator.meta.kind === 'maybe' && redactedTypes.includes(inputValidator.meta.type) && typeof input === 'string' ) { return redactedString; } if ( inputValidator.meta.kind !== 'interface' || typeof input !== 'object' || !input ) { return input; } const result = {}; for (const key in input) { const value = input[key]; const validator = inputValidator.meta.props[key]; result[key] = sanitizeInput(validator, value); } return result; } function findFirstInputMatchingValidator( wholeInputValidator: *, inputValidatorToMatch: *, input: *, ): any { if (!wholeInputValidator || input === null || input === undefined) { return null; } if ( wholeInputValidator === inputValidatorToMatch && wholeInputValidator.is(input) ) { return input; } if (wholeInputValidator.meta.kind === 'maybe') { return findFirstInputMatchingValidator( wholeInputValidator.meta.type, inputValidatorToMatch, input, ); } if ( wholeInputValidator.meta.kind === 'interface' && typeof input === 'object' ) { for (const key in input) { const value = input[key]; const validator = wholeInputValidator.meta.props[key]; const innerResult = findFirstInputMatchingValidator( validator, inputValidatorToMatch, value, ); if (innerResult) { return innerResult; } } } if (wholeInputValidator.meta.kind === 'union') { for (const validator of wholeInputValidator.meta.types) { if (validator.is(input)) { return findFirstInputMatchingValidator( validator, inputValidatorToMatch, input, ); } } } if (wholeInputValidator.meta.kind === 'list' && Array.isArray(input)) { const validator = wholeInputValidator.meta.type; for (const value of input) { const innerResult = findFirstInputMatchingValidator( validator, inputValidatorToMatch, value, ); if (innerResult) { return innerResult; } } } return null; } async function policiesValidator( viewer: Viewer, policies: $ReadOnlyArray, ) { if (!policies.length) { return; } if (!hasMinCodeVersion(viewer.platformDetails, 1000)) { return; } const notAcknowledgedPolicies = await fetchNotAcknowledgedPolicies( - viewer, + viewer.id, policies, ); if (notAcknowledgedPolicies.length) { throw new ServerError('policies_not_accepted', { notAcknowledgedPolicies, }); } } export { validateInput, checkInputValidator, checkClientSupported, policiesValidator, }; diff --git a/lib/actions/siwe-actions.js b/lib/actions/siwe-actions.js index 434b471a7..405292c59 100644 --- a/lib/actions/siwe-actions.js +++ b/lib/actions/siwe-actions.js @@ -1,69 +1,70 @@ // @flow import threadWatcher from '../shared/thread-watcher.js'; import { type LogInResult, logInActionSources, } from '../types/account-types.js'; import type { SIWEAuthServerCall } from '../types/siwe-types.js'; import type { CallServerEndpoint } from '../utils/call-server-endpoint'; import { getConfig } from '../utils/config.js'; import { mergeUserInfos } from './user-actions.js'; const getSIWENonceActionTypes = Object.freeze({ started: 'GET_SIWE_NONCE_STARTED', success: 'GET_SIWE_NONCE_SUCCESS', failed: 'GET_SIWE_NONCE_FAILED', }); const getSIWENonce = ( callServerEndpoint: CallServerEndpoint, ): (() => Promise) => async () => { const response = await callServerEndpoint('siwe_nonce'); return response.nonce; }; const siweAuthActionTypes = Object.freeze({ started: 'SIWE_AUTH_STARTED', success: 'SIWE_AUTH_SUCCESS', failed: 'SIWE_AUTH_FAILED', }); const siweAuthCallServerEndpointOptions = { timeout: 60000 }; const siweAuth = ( callServerEndpoint: CallServerEndpoint, ): (( siweAuthPayload: SIWEAuthServerCall, ) => Promise) => async siweAuthPayload => { const watchedIDs = threadWatcher.getWatchedIDs(); const response = await callServerEndpoint( 'siwe_auth', { ...siweAuthPayload, watchedIDs, platformDetails: getConfig().platformDetails, }, siweAuthCallServerEndpointOptions, ); const userInfos = mergeUserInfos( response.userInfos, response.cookieChange.userInfos, ); return { threadInfos: response.cookieChange.threadInfos, currentUserInfo: response.currentUserInfo, calendarResult: { calendarQuery: siweAuthPayload.calendarQuery, rawEntryInfos: response.rawEntryInfos, }, messagesResult: { messageInfos: response.rawMessageInfos, truncationStatus: response.truncationStatuses, watchedIDsAtRequestTime: watchedIDs, currentAsOf: response.serverTime, }, userInfos, updatesCurrentAsOf: response.serverTime, logInActionSource: logInActionSources.logInFromNativeSIWE, + notAcknowledgedPolicies: response.notAcknowledgedPolicies, }; }; export { getSIWENonceActionTypes, getSIWENonce, siweAuthActionTypes, siweAuth }; diff --git a/lib/actions/user-actions.js b/lib/actions/user-actions.js index 82b8aa8fa..28dc38079 100644 --- a/lib/actions/user-actions.js +++ b/lib/actions/user-actions.js @@ -1,260 +1,261 @@ // @flow import threadWatcher from '../shared/thread-watcher'; import type { LogOutResult, LogInInfo, LogInResult, RegisterResult, RegisterInfo, UpdateUserSettingsRequest, PolicyAcknowledgmentRequest, } from '../types/account-types'; import type { GetSessionPublicKeysArgs } from '../types/request-types'; import type { UserSearchResult } from '../types/search-types'; import type { SessionPublicKeys, PreRequestUserState, } from '../types/session-types'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResult, } from '../types/subscription-types'; import type { UserInfo, PasswordUpdate } from '../types/user-types'; import type { CallServerEndpoint } from '../utils/call-server-endpoint'; import { getConfig } from '../utils/config'; import sleep from '../utils/sleep'; const logOutActionTypes = Object.freeze({ started: 'LOG_OUT_STARTED', success: 'LOG_OUT_SUCCESS', failed: 'LOG_OUT_FAILED', }); const logOut = ( callServerEndpoint: CallServerEndpoint, ): (( preRequestUserState: PreRequestUserState, ) => Promise) => async preRequestUserState => { let response = null; try { response = await Promise.race([ callServerEndpoint('log_out', {}), (async () => { await sleep(500); throw new Error('log_out took more than 500ms'); })(), ]); } catch {} const currentUserInfo = response ? response.currentUserInfo : null; return { currentUserInfo, preRequestUserState }; }; const deleteAccountActionTypes = Object.freeze({ started: 'DELETE_ACCOUNT_STARTED', success: 'DELETE_ACCOUNT_SUCCESS', failed: 'DELETE_ACCOUNT_FAILED', }); const deleteAccount = ( callServerEndpoint: CallServerEndpoint, ): (( password: ?string, preRequestUserState: PreRequestUserState, ) => Promise) => async (password, preRequestUserState) => { const response = await callServerEndpoint('delete_account', { password }); return { currentUserInfo: response.currentUserInfo, preRequestUserState }; }; const registerActionTypes = Object.freeze({ started: 'REGISTER_STARTED', success: 'REGISTER_SUCCESS', failed: 'REGISTER_FAILED', }); const registerCallServerEndpointOptions = { timeout: 60000 }; const register = ( callServerEndpoint: CallServerEndpoint, ): (( registerInfo: RegisterInfo, ) => Promise) => async registerInfo => { const response = await callServerEndpoint( 'create_account', { ...registerInfo, platformDetails: getConfig().platformDetails, }, registerCallServerEndpointOptions, ); return { currentUserInfo: response.currentUserInfo, rawMessageInfos: response.rawMessageInfos, threadInfos: response.cookieChange.threadInfos, userInfos: response.cookieChange.userInfos, calendarQuery: registerInfo.calendarQuery, }; }; function mergeUserInfos(...userInfoArrays: UserInfo[][]): UserInfo[] { const merged = {}; for (const userInfoArray of userInfoArrays) { for (const userInfo of userInfoArray) { merged[userInfo.id] = userInfo; } } const flattened = []; for (const id in merged) { flattened.push(merged[id]); } return flattened; } const logInActionTypes = Object.freeze({ started: 'LOG_IN_STARTED', success: 'LOG_IN_SUCCESS', failed: 'LOG_IN_FAILED', }); const logInCallServerEndpointOptions = { timeout: 60000 }; const logIn = ( callServerEndpoint: CallServerEndpoint, ): ((logInInfo: LogInInfo) => Promise) => async logInInfo => { const watchedIDs = threadWatcher.getWatchedIDs(); const { logInActionSource, ...restLogInInfo } = logInInfo; const response = await callServerEndpoint( 'log_in', { ...restLogInInfo, source: logInActionSource, watchedIDs, platformDetails: getConfig().platformDetails, }, logInCallServerEndpointOptions, ); const userInfos = mergeUserInfos( response.userInfos, response.cookieChange.userInfos, ); return { threadInfos: response.cookieChange.threadInfos, currentUserInfo: response.currentUserInfo, calendarResult: { calendarQuery: logInInfo.calendarQuery, rawEntryInfos: response.rawEntryInfos, }, messagesResult: { messageInfos: response.rawMessageInfos, truncationStatus: response.truncationStatuses, watchedIDsAtRequestTime: watchedIDs, currentAsOf: response.serverTime, }, userInfos, updatesCurrentAsOf: response.serverTime, logInActionSource: logInInfo.logInActionSource, + notAcknowledgedPolicies: response.notAcknowledgedPolicies, }; }; const changeUserPasswordActionTypes = Object.freeze({ started: 'CHANGE_USER_PASSWORD_STARTED', success: 'CHANGE_USER_PASSWORD_SUCCESS', failed: 'CHANGE_USER_PASSWORD_FAILED', }); const changeUserPassword = ( callServerEndpoint: CallServerEndpoint, ): (( passwordUpdate: PasswordUpdate, ) => Promise) => async passwordUpdate => { await callServerEndpoint('update_account', passwordUpdate); }; const searchUsersActionTypes = Object.freeze({ started: 'SEARCH_USERS_STARTED', success: 'SEARCH_USERS_SUCCESS', failed: 'SEARCH_USERS_FAILED', }); const searchUsers = ( callServerEndpoint: CallServerEndpoint, ): (( usernamePrefix: string, ) => Promise) => async usernamePrefix => { const response = await callServerEndpoint('search_users', { prefix: usernamePrefix, }); return { userInfos: response.userInfos, }; }; const updateSubscriptionActionTypes = Object.freeze({ started: 'UPDATE_SUBSCRIPTION_STARTED', success: 'UPDATE_SUBSCRIPTION_SUCCESS', failed: 'UPDATE_SUBSCRIPTION_FAILED', }); const updateSubscription = ( callServerEndpoint: CallServerEndpoint, ): (( subscriptionUpdate: SubscriptionUpdateRequest, ) => Promise) => async subscriptionUpdate => { const response = await callServerEndpoint( 'update_user_subscription', subscriptionUpdate, ); return { threadID: subscriptionUpdate.threadID, subscription: response.threadSubscription, }; }; const setUserSettingsActionTypes = Object.freeze({ started: 'SET_USER_SETTINGS_STARTED', success: 'SET_USER_SETTINGS_SUCCESS', failed: 'SET_USER_SETTINGS_FAILED', }); const setUserSettings = ( callServerEndpoint: CallServerEndpoint, ): (( userSettingsRequest: UpdateUserSettingsRequest, ) => Promise) => async userSettingsRequest => { await callServerEndpoint('update_user_settings', userSettingsRequest); }; const getSessionPublicKeys = ( callServerEndpoint: CallServerEndpoint, ): (( data: GetSessionPublicKeysArgs, ) => Promise) => async data => { return await callServerEndpoint('get_session_public_keys', data); }; const policyAcknowledgmentActionTypes = Object.freeze({ started: 'POLICY_ACKNOWLEDGMENT_STARTED', success: 'POLICY_ACKNOWLEDGMENT_SUCCESS', failed: 'POLICY_ACKNOWLEDGMENT_FAILED', }); const policyAcknowledgment = ( callServerEndpoint: CallServerEndpoint, ): (( policyRequest: PolicyAcknowledgmentRequest, ) => Promise) => async policyRequest => { await callServerEndpoint('policy_acknowledgment', policyRequest); }; export { changeUserPasswordActionTypes, changeUserPassword, deleteAccount, deleteAccountActionTypes, getSessionPublicKeys, mergeUserInfos, logIn, logInActionTypes, logOut, logOutActionTypes, register, registerActionTypes, searchUsers, searchUsersActionTypes, setUserSettings, setUserSettingsActionTypes, updateSubscription, updateSubscriptionActionTypes, policyAcknowledgment, policyAcknowledgmentActionTypes, }; diff --git a/lib/facts/policies.js b/lib/facts/policies.js index 20f4be30a..fc33fb626 100644 --- a/lib/facts/policies.js +++ b/lib/facts/policies.js @@ -1,11 +1,13 @@ // @flow import { values } from '../utils/objects.js'; export const policyTypes = Object.freeze({ tosAndPrivacyPolicy: 'TERMS_OF_USE_AND_PRIVACY_POLICY', }); export const policies: $ReadOnlyArray = values(policyTypes); export type PolicyType = $Values; + +export const baseLegalPolicies = [policyTypes.tosAndPrivacyPolicy]; diff --git a/lib/reducers/policies-reducer.js b/lib/reducers/policies-reducer.js index ef9798f18..9d4493d5c 100644 --- a/lib/reducers/policies-reducer.js +++ b/lib/reducers/policies-reducer.js @@ -1,40 +1,51 @@ // @flow -import { policyAcknowledgmentActionTypes } from '../actions/user-actions.js'; +import { siweAuthActionTypes } from '../actions/siwe-actions.js'; +import { + logInActionTypes, + policyAcknowledgmentActionTypes, +} from '../actions/user-actions.js'; import type { PolicyType } from '../facts/policies.js'; import { type UserPolicies, forcePolicyAcknowledgmentActionType, } from '../types/policy-types.js'; import type { BaseAction } from '../types/redux-types'; function policiesReducer( state: UserPolicies, action: BaseAction, ): UserPolicies { - if (action.type === forcePolicyAcknowledgmentActionType) { + if ( + action.type === forcePolicyAcknowledgmentActionType || + action.type === logInActionTypes.success || + action.type === siweAuthActionTypes.success + ) { const { notAcknowledgedPolicies } = action.payload; + if (!notAcknowledgedPolicies) { + return state; + } const newState = { ...state }; notAcknowledgedPolicies.forEach((policy: PolicyType) => { newState[policy] = { ...newState[policy], isAcknowledged: false, }; }); return newState; } if (action.type === policyAcknowledgmentActionTypes.success) { const { policy } = action.payload; return { ...state, [policy]: { ...state[policy], isAcknowledged: true, }, }; } return state; } export default policiesReducer; diff --git a/lib/types/account-types.js b/lib/types/account-types.js index 9b2f0e53f..8663e6285 100644 --- a/lib/types/account-types.js +++ b/lib/types/account-types.js @@ -1,184 +1,186 @@ // @flow import type { PolicyType } from '../facts/policies.js'; import { values } from '../utils/objects'; import type { PlatformDetails } from './device-types'; import type { CalendarQuery, CalendarResult, RawEntryInfo, } from './entry-types'; import type { RawMessageInfo, MessageTruncationStatuses, GenericMessagesResult, } from './message-types'; import type { PreRequestUserState } from './session-types'; import type { RawThreadInfo } from './thread-types'; import type { UserInfo, LoggedOutUserInfo, LoggedInUserInfo, OldLoggedInUserInfo, } from './user-types'; export type ResetPasswordRequest = { +usernameOrEmail: string, }; export type LogOutResult = { +currentUserInfo: ?LoggedOutUserInfo, +preRequestUserState: PreRequestUserState, }; export type LogOutResponse = { +currentUserInfo: LoggedOutUserInfo, }; export type RegisterInfo = { ...LogInExtraInfo, +username: string, +password: string, }; type DeviceTokenUpdateRequest = { +deviceToken: string, }; export type RegisterRequest = { +username: string, +password: string, +calendarQuery?: ?CalendarQuery, +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, +platformDetails: PlatformDetails, }; export type RegisterResponse = { id: string, rawMessageInfos: $ReadOnlyArray, currentUserInfo: OldLoggedInUserInfo | LoggedInUserInfo, cookieChange: { threadInfos: { +[id: string]: RawThreadInfo }, userInfos: $ReadOnlyArray, }, }; export type RegisterResult = { +currentUserInfo: LoggedInUserInfo, +rawMessageInfos: $ReadOnlyArray, +threadInfos: { +[id: string]: RawThreadInfo }, +userInfos: $ReadOnlyArray, +calendarQuery: CalendarQuery, }; export type DeleteAccountRequest = { +password: ?string, }; export const logInActionSources = Object.freeze({ cookieInvalidationResolutionAttempt: 'COOKIE_INVALIDATION_RESOLUTION_ATTEMPT', appStartCookieLoggedInButInvalidRedux: 'APP_START_COOKIE_LOGGED_IN_BUT_INVALID_REDUX', appStartReduxLoggedInButInvalidCookie: 'APP_START_REDUX_LOGGED_IN_BUT_INVALID_COOKIE', socketAuthErrorResolutionAttempt: 'SOCKET_AUTH_ERROR_RESOLUTION_ATTEMPT', sqliteOpFailure: 'SQLITE_OP_FAILURE', sqliteLoadFailure: 'SQLITE_LOAD_FAILURE', logInFromWebForm: 'LOG_IN_FROM_WEB_FORM', logInFromNativeForm: 'LOG_IN_FROM_NATIVE_FORM', logInFromNativeSIWE: 'LOG_IN_FROM_NATIVE_SIWE', }); export type LogInActionSource = $Values; export type LogInStartingPayload = { +calendarQuery: CalendarQuery, +logInActionSource?: LogInActionSource, }; export type LogInExtraInfo = { +calendarQuery: CalendarQuery, +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, }; export type LogInInfo = { ...LogInExtraInfo, +username: string, +password: string, +logInActionSource: LogInActionSource, }; export type LogInRequest = { +usernameOrEmail?: ?string, +username?: ?string, +password: string, +calendarQuery?: ?CalendarQuery, +deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, +platformDetails: PlatformDetails, +watchedIDs: $ReadOnlyArray, +source?: LogInActionSource, }; export type LogInResponse = { - currentUserInfo: LoggedInUserInfo | OldLoggedInUserInfo, - rawMessageInfos: $ReadOnlyArray, - truncationStatuses: MessageTruncationStatuses, - userInfos: $ReadOnlyArray, - rawEntryInfos?: ?$ReadOnlyArray, - serverTime: number, - cookieChange: { - threadInfos: { +[id: string]: RawThreadInfo }, - userInfos: $ReadOnlyArray, + +currentUserInfo: LoggedInUserInfo | OldLoggedInUserInfo, + +rawMessageInfos: $ReadOnlyArray, + +truncationStatuses: MessageTruncationStatuses, + +userInfos: $ReadOnlyArray, + +rawEntryInfos?: ?$ReadOnlyArray, + +serverTime: number, + +cookieChange: { + +threadInfos: { +[id: string]: RawThreadInfo }, + +userInfos: $ReadOnlyArray, }, + +notAcknowledgedPolicies?: $ReadOnlyArray, }; export type LogInResult = { +threadInfos: { +[id: string]: RawThreadInfo }, +currentUserInfo: LoggedInUserInfo, +messagesResult: GenericMessagesResult, +userInfos: $ReadOnlyArray, +calendarResult: CalendarResult, +updatesCurrentAsOf: number, +logInActionSource: LogInActionSource, + +notAcknowledgedPolicies?: $ReadOnlyArray, }; export type UpdatePasswordRequest = { code: string, password: string, calendarQuery?: ?CalendarQuery, deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, platformDetails: PlatformDetails, watchedIDs: $ReadOnlyArray, }; export type PolicyAcknowledgmentRequest = { +policy: PolicyType, }; export type EmailSubscriptionRequest = { +email: string, }; export type UpdateUserSettingsRequest = { +name: 'default_user_notifications', +data: NotificationTypes, }; export const userSettingsTypes = Object.freeze({ DEFAULT_NOTIFICATIONS: 'default_user_notifications', }); export type DefaultNotificationPayload = { +default_user_notifications: ?NotificationTypes, }; export const notificationTypes = Object.freeze({ FOCUSED: 'focused', BADGE_ONLY: 'badge_only', BACKGROUND: 'background', }); export type NotificationTypes = $Values; export const notificationTypeValues: $ReadOnlyArray = values( notificationTypes, );