Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/app/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export const HTTP_STATUS_CODES = {
PAYMENT_REQUIRED: 402,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
MAX_SPACE_USED: 420,
INTERNAL_SERVER_ERROR: 500,
};
Expand Down
24 changes: 21 additions & 3 deletions src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ type SignUpParams = {
dispatch: AppDispatch;
};

type NewAccountSessionParams = {
registeredUser: Awaited<ReturnType<RegisterFunction>>;
password: string;
redeemCodeObject: boolean;
dispatch: AppDispatch;
};

type LogInParams = {
email: string;
password: string;
Expand Down Expand Up @@ -567,9 +574,13 @@ export const unblockAccount = (token: string): Promise<void> => {
return authClient.unblockAccount(token);
};

export const signUp = async (params: SignUpParams) => {
const { doSignUp, email, password, token, redeemCodeObject, dispatch } = params;
const { xUser, xNewToken, mnemonic } = await doSignUp(email, password, token);
export const startNewAccountSession = async ({
registeredUser,
password,
redeemCodeObject,
dispatch,
}: NewAccountSessionParams) => {
const { xUser, xNewToken, mnemonic } = registeredUser;

localStorageService.clearExcept([...CHECKOUT_LOCAL_STORAGE_ITEMS, ...ATTRIBUTION_LOCAL_STORAGE_ITEMS]);

Expand Down Expand Up @@ -601,6 +612,13 @@ export const signUp = async (params: SignUpParams) => {
return { user: xUser, mnemonic, newToken: xNewToken };
};

export const signUp = async (params: SignUpParams) => {
const { doSignUp, email, password, token, redeemCodeObject, dispatch } = params;
const registeredUser = await doSignUp(email, password, token);

return startNewAccountSession({ registeredUser, password, redeemCodeObject, dispatch });
};

export const logIn = async (params: LogInParams): Promise<ProfileInfo> => {
const { email, password, twoFactorCode, dispatch, loginType = 'web', turnstileToken, knownSecurityDetails } = params;
const { newToken, user, mnemonic } = await doLogin(
Expand Down
29 changes: 29 additions & 0 deletions src/testUtils/fixtures/accountSetup.fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { CompleteAccountSetupPayload, RegisterResponse } from '@internxt/sdk/dist/auth/types';

export const getCompleteAccountSetupResponse = (
payload: CompleteAccountSetupPayload,
overrides: Partial<RegisterResponse> = {},
): RegisterResponse =>
({
token: 'legacy-session-token',
newToken: 'session-token',
uuid: 'user-uuid',
user: {
uuid: 'user-uuid',
userId: 'user-id',
email: 'paid-user@internxt.com',
name: payload.name,
lastname: payload.lastname,
mnemonic: payload.mnemonic,
rootFolderId: 'root-folder-uuid',
referralCode: 'referral-code',
keys: {
ecc: { publicKey: payload.keys.ecc.publicKey, privateKey: payload.keys.ecc.privateKeyEncrypted },
kyber: {
publicKey: payload.keys.kyber.publicKey ?? '',
privateKey: payload.keys.kyber.privateKeyEncrypted ?? '',
},
},
},
...overrides,
}) as RegisterResponse;
51 changes: 51 additions & 0 deletions src/views/AccountSetup/hooks/useAccountSetup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { HTTP_STATUS_CODES } from 'app/core/constants';
import { AppView } from 'app/core/types';
import { useAppDispatch } from 'app/store/hooks';
import { useState } from 'react';
import errorService from 'services/error.service';
import navigationService from 'services/navigation.service';
import { completeAccountSetup } from '../services/completeAccountSetup';

export type AccountSetupStatus = 'form' | 'invalidLink' | 'accountAlreadyExists';

/**
* Messages the backend sends when the link can no longer be used. Other 403s (e.g. a failed
* captcha) must not be shown as an invalid link.
*/
const INVALID_LINK_MESSAGES = new Set(['Invalid token', 'Token expired']);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is quite likely to stop working if there is even the slightest change to the message. Perhaps the status code alone should be enough, with the message included as an extra in the check


export const useAccountSetup = (setupToken: string) => {
const dispatch = useAppDispatch();
const [status, setStatus] = useState<AccountSetupStatus>('form');
const [isSubmitting, setIsSubmitting] = useState(false);
const [hasSubmitFailed, setHasSubmitFailed] = useState(false);

const handleSetupError = (error: unknown) => {
const { status: httpStatus, message } = errorService.castError(error);
const isInvalidLink = httpStatus === HTTP_STATUS_CODES.FORBIDDEN && INVALID_LINK_MESSAGES.has(message);

if (isInvalidLink) {
setStatus('invalidLink');
} else if (httpStatus === HTTP_STATUS_CODES.CONFLICT) {
setStatus('accountAlreadyExists');
} else {
errorService.reportError(error);
setHasSubmitFailed(true);
}
};

const setUpAccount = async (password: string) => {
setIsSubmitting(true);
setHasSubmitFailed(false);

try {
await completeAccountSetup({ setupToken, password, dispatch });
navigationService.push(AppView.Drive);
} catch (error) {
handleSetupError(error);
setIsSubmitting(false);
}
};

return { status, isSubmitting, hasSubmitFailed, setUpAccount };
};
31 changes: 31 additions & 0 deletions src/views/AccountSetup/hooks/useResendAccountSetupEmail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useTranslationContext } from 'app/i18n/provider/TranslationProvider';
import notificationsService, { ToastType } from 'app/notifications/services/notifications.service';
import { useState } from 'react';
import { resendAccountSetupEmail } from 'services/account-setup.service';
import errorService from 'services/error.service';

export const useResendAccountSetupEmail = () => {
const { translate } = useTranslationContext();
const [isSending, setIsSending] = useState(false);
const [isEmailSent, setIsEmailSent] = useState(false);

const resendEmail = async (email: string) => {
setIsSending(true);

try {
await resendAccountSetupEmail(email);
setIsEmailSent(true);
} catch (error) {
errorService.reportError(error);
notificationsService.show({
text: translate('accountSetup.invalidLink.resendError'),
type: ToastType.Error,
requestId: errorService.castError(error).requestId,
});
} finally {
setIsSending(false);
}
};

return { isSending, isEmailSent, resendEmail };
};
105 changes: 105 additions & 0 deletions src/views/AccountSetup/services/completeAccountSetup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { CompleteAccountSetupPayload } from '@internxt/sdk/dist/auth/types';
import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings';
import { decryptText, decryptTextWithKey, passToHash } from 'app/crypto/services/utils';
import { planThunks } from 'app/store/slices/plan';
import { userThunks } from 'app/store/slices/user';
import { validateMnemonic } from 'bip39';
import { Buffer } from 'node:buffer';
import encryptedStorageService from 'services/encrypted-storage.service';
import envService from 'services/env.service';
import { getCompleteAccountSetupResponse } from 'testUtils/fixtures/accountSetup.fixtures';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { completeAccountSetup } from './completeAccountSetup';

const { authClient, createAuthClient } = vi.hoisted(() => {
const authClient = { completeAccountSetup: vi.fn() };
return { authClient, createAuthClient: vi.fn() };
});

vi.mock('app/core/factory/sdk', () => ({
SdkFactory: { getNewApiInstance: () => ({ createAuthClient }) },
}));
vi.mock('services/encrypted-storage.service', () => ({
default: { setToken: vi.fn(), getToken: vi.fn(), getUser: vi.fn(), clear: vi.fn() },
}));
vi.mock('services/local-storage.service', () => ({
default: { get: vi.fn(), set: vi.fn(), clear: vi.fn(), clearExcept: vi.fn() },
}));
vi.mock('app/store/slices/user', () => ({
initializeUserThunk: vi.fn(),
userThunks: { setUserThunk: vi.fn(), initializeUserThunk: vi.fn() },
}));
vi.mock('app/store/slices/plan', () => ({ planThunks: { initializeThunk: vi.fn() } }));
vi.mock('app/store/slices/workspaces/workspacesStore', () => ({
workspaceThunks: { fetchWorkspaces: vi.fn(), checkAndSetLocalWorkspace: vi.fn() },
}));
vi.mock('services/navigation.service', () => ({ default: { push: vi.fn(), isCurrentPath: vi.fn() } }));
vi.mock('app/analytics/impact.service', () => ({ trackSignUp: vi.fn() }));
vi.mock('app/analytics/meta.service', () => ({ trackLead: vi.fn() }));

const SETUP_TOKEN = 'setup-token-from-email';
const PASSWORD = 'Correct-Horse-Battery-9!';
const CAPTCHA_TOKEN = 'captcha-token';

const sentPayload = (): CompleteAccountSetupPayload => authClient.completeAccountSetup.mock.calls[0][0];

describe('Completing the setup of a paid account', () => {
beforeEach(() => {
vi.clearAllMocks();
globalThis.Buffer = Buffer;
globalThis.grecaptcha = {
ready: (callback: () => void) => callback(),
execute: vi.fn().mockResolvedValue(CAPTCHA_TOKEN),
} as never;
vi.spyOn(envService, 'getVariable').mockImplementation((key) => (key === 'secret' ? 'crypto-secret' : 'value'));
createAuthClient.mockReturnValue(authClient);
authClient.completeAccountSetup.mockImplementation(async (payload: CompleteAccountSetupPayload) =>
getCompleteAccountSetupResponse(payload),
);
});

test('When the account is set up, then the chosen password never leaves the browser in plain text', async () => {
await completeAccountSetup({ setupToken: SETUP_TOKEN, password: PASSWORD, dispatch: vi.fn() });

const payload = sentPayload();
expect(JSON.stringify(payload)).not.toContain(PASSWORD);
const salt = decryptText(payload.salt);
expect(decryptText(payload.password)).toBe(passToHash({ password: PASSWORD, salt }).hash);
});

test('When the account is set up, then a new recovery phrase and key pair protected by the password are sent with the link token', async () => {
await completeAccountSetup({ setupToken: SETUP_TOKEN, password: PASSWORD, dispatch: vi.fn() });

const payload = sentPayload();
expect(payload.token).toBe(SETUP_TOKEN);
expect(payload).toMatchObject({ name: 'My', lastname: 'Internxt' });
expect(validateMnemonic(decryptTextWithKey(payload.mnemonic, PASSWORD))).toBe(true);
expect(Buffer.from(payload.keys.ecc.publicKey, 'base64').toString()).toContain('BEGIN PGP PUBLIC KEY');
expect(payload.keys.kyber.publicKey).toBeTruthy();
expect(createAuthClient).toHaveBeenCalledWith({ captchaToken: CAPTCHA_TOKEN });
});

test('When the account is set up, then the user is logged in with their decrypted keys and paid plan', async () => {
const dispatch = vi.fn();

const session = await completeAccountSetup({ setupToken: SETUP_TOKEN, password: PASSWORD, dispatch });

expect(encryptedStorageService.setToken).toHaveBeenCalledWith('session-token');
expect(validateMnemonic(session.mnemonic)).toBe(true);
const loggedUser: UserSettings = vi.mocked(userThunks.setUserThunk).mock.calls[0][0];
expect(loggedUser.mnemonic).toBe(session.mnemonic);
expect(Buffer.from(loggedUser.keys.ecc.privateKey, 'base64').toString()).toContain('BEGIN PGP PRIVATE KEY');
expect(planThunks.initializeThunk).toHaveBeenCalled();
});

test('When the link is rejected, then the error reaches the caller and nobody is logged in', async () => {
const expiredLinkError = Object.assign(new Error('Token expired'), { status: 403 });
authClient.completeAccountSetup.mockRejectedValue(expiredLinkError);

await expect(completeAccountSetup({ setupToken: SETUP_TOKEN, password: PASSWORD, dispatch: vi.fn() })).rejects.toBe(
expiredLinkError,
);
expect(encryptedStorageService.setToken).not.toHaveBeenCalled();
expect(userThunks.setUserThunk).not.toHaveBeenCalled();
});
});
34 changes: 34 additions & 0 deletions src/views/AccountSetup/services/completeAccountSetup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings';
import { SdkFactory } from 'app/core/factory/sdk';
import { decryptTextWithKey } from 'app/crypto/services/utils';
import { AppDispatch } from 'app/store';
import { startNewAccountSession } from 'services/auth.service';
import { generateCaptchaToken } from 'utils';
import { generateNewAccountCredentials } from 'views/Signup/utils/generateNewAccountCredentials';

interface CompleteAccountSetupParams {
setupToken: string;
password: string;
dispatch: AppDispatch;
}

export const completeAccountSetup = async ({ setupToken, password, dispatch }: CompleteAccountSetupParams) => {
const captchaToken = await generateCaptchaToken();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Cloudflare’s Turnstile was recently added to the login process. I’m just mentioning this so we bear it in mind in case, in future, we prefer to use a single type of captcha for everything

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yep, I know, but payments still works with captcha token... I have in mind to migrate it to Turnstile

const authClient = SdkFactory.getNewApiInstance().createAuthClient({ captchaToken });
const credentials = await generateNewAccountCredentials(password);

const { user, token, newToken } = await authClient.completeAccountSetup({ token: setupToken, ...credentials });
const mnemonic = decryptTextWithKey(user.mnemonic, password);

return startNewAccountSession({
registeredUser: {
xUser: { ...user, mnemonic } as unknown as UserSettings,
xToken: token,
xNewToken: newToken,
mnemonic,
},
password,
redeemCodeObject: false,
dispatch,
});
};
41 changes: 5 additions & 36 deletions src/views/Signup/hooks/useSignup.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { RegisterDetails } from '@internxt/sdk';
import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings';
import * as bip39 from 'bip39';

import { readReferalCookie, RegisterFunction } from 'services/auth.service';
import { SdkFactory } from '../../../app/core/factory/sdk';
import { getKeys } from '../../../app/crypto/services/keys.service';
import { decryptTextWithKey, encryptText, encryptTextWithKey, passToHash } from '../../../app/crypto/services/utils';
import { decryptTextWithKey } from '../../../app/crypto/services/utils';
import { generateCaptchaToken } from 'utils';
import { generateNewAccountCredentials } from '../utils/generateNewAccountCredentials';

type RegisterPreCreatedUser = (
email: string,
Expand All @@ -25,28 +24,9 @@ export function useSignUp(referrer?: string): {
doRegisterPreCreatedUser: RegisterPreCreatedUser;
} {
const doRegister = async (email: string, password: string, captcha: string) => {
const hashObj = passToHash({ password });
const encPass = encryptText(hashObj.hash);
const encSalt = encryptText(hashObj.salt);
const mnemonic = bip39.generateMnemonic(256);
const encMnemonic = encryptTextWithKey(mnemonic, password);

const authClient = SdkFactory.getNewApiInstance().createAuthClient();

const keys = await getKeys(password);

const registerDetails: RegisterDetails = {
name: 'My',
lastname: 'Internxt',
email: email.toLowerCase(),
password: encPass,
salt: encSalt,
mnemonic: encMnemonic,
keys: keys,
captcha: captcha,
referral: readReferalCookie(),
referrer: referrer,
};
const registerDetails = await generateRegisterDetails(email, password, captcha);

const data = await authClient.register(registerDetails);
const { token, newToken } = data;
Expand Down Expand Up @@ -91,21 +71,10 @@ export function useSignUp(referrer?: string): {
password: string,
captcha: string,
): Promise<RegisterDetails> => {
const hashObj = passToHash({ password });
const encPass = encryptText(hashObj.hash);
const encSalt = encryptText(hashObj.salt);
const mnemonic = bip39.generateMnemonic(256);
const encMnemonic = encryptTextWithKey(mnemonic, password);

const keys = await getKeys(password);
const credentials = await generateNewAccountCredentials(password);
const registerDetails: RegisterDetails = {
name: 'My',
lastname: 'Internxt',
...credentials,
email: email.toLowerCase(),
password: encPass,
salt: encSalt,
mnemonic: encMnemonic,
keys: keys,
captcha: captcha,
referral: readReferalCookie(),
referrer: referrer,
Expand Down
Loading
Loading