diff --git a/backend/src/controllers/v3/index.ts b/backend/src/controllers/v3/index.ts index ffe25a6c2..9d3f118d3 100644 --- a/backend/src/controllers/v3/index.ts +++ b/backend/src/controllers/v3/index.ts @@ -1,9 +1,11 @@ import * as secretsController from './secretsController'; import * as workspacesController from './workspacesController'; import * as authController from './authController'; +import * as signupController from './signupController'; export { authController, secretsController, + signupController, workspacesController, } diff --git a/backend/src/controllers/v3/signupController.ts b/backend/src/controllers/v3/signupController.ts new file mode 100644 index 000000000..5be22b308 --- /dev/null +++ b/backend/src/controllers/v3/signupController.ts @@ -0,0 +1,177 @@ +import jwt from 'jsonwebtoken'; +import { Request, Response } from 'express'; +import * as Sentry from '@sentry/node'; +import { User, MembershipOrg } from '../../models'; +import { completeAccount } from '../../helpers/user'; +import { + initializeDefaultOrg +} from '../../helpers/signup'; +import { issueAuthTokens, validateProviderAuthToken } from '../../helpers/auth'; +import { INVITED, ACCEPTED } from '../../variables'; +import request from '../../config/request'; +import { getLoopsApiKey, getHttpsEnabled, getJwtSignupSecret } from '../../config'; +import { BadRequestError } from '../../utils/errors'; + +/** + * Complete setting up user by adding their personal and auth information as part of the + * signup flow + * @param req + * @param res + * @returns + */ +export const completeAccountSignup = async (req: Request, res: Response) => { + let user, token, refreshToken; + try { + const { + email, + firstName, + lastName, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier, + organizationName, + providerAuthToken, + }: { + email: string; + firstName: string; + lastName: string; + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; + publicKey: string; + encryptedPrivateKey: string; + encryptedPrivateKeyIV: string; + encryptedPrivateKeyTag: string; + salt: string; + verifier: string; + organizationName: string; + providerAuthToken?: string; + } = req.body; + + user = await User.findOne({ email }); + + if (!user || (user && user?.publicKey)) { + // case 1: user doesn't exist. + // case 2: user has already completed account + return res.status(403).send({ + error: 'Failed to complete account for complete user' + }); + } + + if (providerAuthToken) { + await validateProviderAuthToken({ + email, + providerAuthToken, + user, + }); + } else { + const [ AUTH_TOKEN_TYPE, AUTH_TOKEN_VALUE ] = <[string, string]>req.headers['authorization']?.split(' ', 2) ?? [null, null] + if(AUTH_TOKEN_TYPE === null) { + throw BadRequestError({message: `Missing Authorization Header in the request header.`}); + } + if(AUTH_TOKEN_TYPE.toLowerCase() !== 'bearer') { + throw BadRequestError({message: `The provided authentication type '${AUTH_TOKEN_TYPE}' is not supported.`}) + } + if(AUTH_TOKEN_VALUE === null) { + throw BadRequestError({ + message: 'Missing Authorization Body in the request header', + }) + } + + const decodedToken = ( + jwt.verify(AUTH_TOKEN_VALUE, await getJwtSignupSecret()) + ); + + if (decodedToken.userId !== user.id) { + throw BadRequestError(); + } + } + + // complete setting up user's account + user = await completeAccount({ + userId: user._id.toString(), + firstName, + lastName, + encryptionVersion: 2, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + salt, + verifier + }); + + if (!user) + throw new Error('Failed to complete account for non-existent user'); // ensure user is non-null + + // initialize default organization and workspace + await initializeDefaultOrg({ + organizationName, + user + }); + + // update organization membership statuses that are + // invited to completed with user attached + await MembershipOrg.updateMany( + { + inviteEmail: email, + status: INVITED + }, + { + user, + status: ACCEPTED + } + ); + + // issue tokens + const tokens = await issueAuthTokens({ + userId: user._id.toString() + }); + + token = tokens.token; + + // sending a welcome email to new users + if (await getLoopsApiKey()) { + await request.post("https://app.loops.so/api/v1/events/send", { + "email": email, + "eventName": "Sign Up", + "firstName": firstName, + "lastName": lastName + }, { + headers: { + "Accept": "application/json", + "Authorization": "Bearer " + (await getLoopsApiKey()) + }, + }); + } + + // store (refresh) token in httpOnly cookie + res.cookie('jid', tokens.refreshToken, { + httpOnly: true, + path: '/', + sameSite: 'strict', + secure: await getHttpsEnabled() + }); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to complete account setup' + }); + } + + return res.status(200).send({ + message: 'Successfully set up account', + user, + token + }); +}; diff --git a/backend/src/index.ts b/backend/src/index.ts index 004a4378f..de89c6f7c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -65,7 +65,8 @@ import { import { auth as v3AuthRouter, secrets as v3SecretsRouter, - workspaces as v3WorkspacesRouter + signup as v3SignupRouter, + workspaces as v3WorkspacesRouter, } from './routes/v3'; import { healthCheck } from './routes/status'; import { getLogger } from './utils/logger'; @@ -170,6 +171,7 @@ const main = async () => { app.use('/api/v3/auth', v3AuthRouter); app.use('/api/v3/secrets', v3SecretsRouter); app.use('/api/v3/workspaces', v3WorkspacesRouter); + app.use('/api/v3/signup', v3SignupRouter); // api docs app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerFile)) diff --git a/backend/src/models/user.ts b/backend/src/models/user.ts index bb5b38d53..843730e0c 100644 --- a/backend/src/models/user.ts +++ b/backend/src/models/user.ts @@ -42,6 +42,7 @@ const userSchema = new Schema( email: { type: String, required: true, + unique: true, }, firstName: { type: String diff --git a/backend/src/routes/v3/index.ts b/backend/src/routes/v3/index.ts index 55f6ec120..2560a8f82 100644 --- a/backend/src/routes/v3/index.ts +++ b/backend/src/routes/v3/index.ts @@ -1,9 +1,11 @@ import auth from './auth'; import secrets from './secrets'; import workspaces from './workspaces'; +import signup from './signup'; export { auth, secrets, - workspaces + signup, + workspaces, } diff --git a/backend/src/routes/v3/signup.ts b/backend/src/routes/v3/signup.ts new file mode 100644 index 000000000..52cf4899e --- /dev/null +++ b/backend/src/routes/v3/signup.ts @@ -0,0 +1,28 @@ +import express from 'express'; +const router = express.Router(); +import { body } from 'express-validator'; +import { signupController } from '../../controllers/v3'; +import { authLimiter } from '../../helpers/rateLimiter'; +import { validateRequest } from '../../middleware'; + +router.post( + '/complete-account/signup', + authLimiter, + body('email').exists().isString().trim().notEmpty().isEmail(), + body('firstName').exists().isString().trim().notEmpty(), + body('lastName').exists().isString().trim().notEmpty(), + body('protectedKey').exists().isString().trim().notEmpty(), + body('protectedKeyIV').exists().isString().trim().notEmpty(), + body('protectedKeyTag').exists().isString().trim().notEmpty(), + body('publicKey').exists().isString().trim().notEmpty(), + body('encryptedPrivateKey').exists().isString().trim().notEmpty(), + body('encryptedPrivateKeyIV').exists().isString().trim().notEmpty(), + body('encryptedPrivateKeyTag').exists().isString().trim().notEmpty(), + body('salt').exists().isString().trim().notEmpty(), + body('verifier').exists().isString().trim().notEmpty(), + body('organizationName').exists().isString().trim().notEmpty(), + validateRequest, + signupController.completeAccountSignup, +); + +export default router; diff --git a/frontend/src/components/login/PasswordInputStep.tsx b/frontend/src/components/login/PasswordInputStep.tsx index b3c881a03..d817fea24 100644 --- a/frontend/src/components/login/PasswordInputStep.tsx +++ b/frontend/src/components/login/PasswordInputStep.tsx @@ -12,7 +12,6 @@ import { getTranslatedStaticProps } from '@app/components/utilities/withTranslat import SecurityClient from '../utilities/SecurityClient'; export default function PasswordInputStep({ - userId, email, password, setPassword, @@ -20,7 +19,6 @@ export default function PasswordInputStep({ setStep }: { email: string; - userId: string; password: string; setPassword: (password: string) => void; setProviderAuthToken: (value: string) => void; @@ -34,13 +32,8 @@ export default function PasswordInputStep({ const handleLogin = async () => { try { - if (!userId || !password) { - return; - } - setIsLoading(true); const isLoginSuccessful = await attemptLogin({ - userId, email, password }); diff --git a/frontend/src/components/signup/UserInfoStep.tsx b/frontend/src/components/signup/UserInfoStep.tsx index 32504cb2c..dc49ce02f 100644 --- a/frontend/src/components/signup/UserInfoStep.tsx +++ b/frontend/src/components/signup/UserInfoStep.tsx @@ -32,6 +32,7 @@ interface UserInfoStepProps { setFirstName: (value: string) => void; lastName: string; setLastName: (value: string) => void; + providerAuthToken?: string; } /** @@ -55,7 +56,8 @@ export default function UserInfoStep({ firstName, setFirstName, lastName, - setLastName + setLastName, + providerAuthToken, }: UserInfoStepProps): JSX.Element { const [firstNameError, setFirstNameError] = useState(false); const [lastNameError, setLastNameError] = useState(false); @@ -118,11 +120,11 @@ export default function UserInfoStep({ parallelism: 1, hashLen: 32 }); - + if (!derivedKey) throw new Error('Failed to derive key from password'); const key = crypto.randomBytes(32); - + // create encrypted private key by encrypting the private // key with the symmetric key [key] const { @@ -133,7 +135,7 @@ export default function UserInfoStep({ text: privateKey, secret: key }); - + // create the protected key by encrypting the symmetric key // [key] with the derived key const { @@ -144,7 +146,7 @@ export default function UserInfoStep({ text: key.toString('hex'), secret: Buffer.from(derivedKey.hash) }); - + const response = await completeAccountInformationSignup({ email, firstName, @@ -156,11 +158,12 @@ export default function UserInfoStep({ encryptedPrivateKey, encryptedPrivateKeyIV, encryptedPrivateKeyTag, + providerAuthToken, salt: result.salt, verifier: result.verifier, - organizationName: `${firstName}'s organization` + organizationName: `${firstName}'s organization`, }); - + // unset signup JWT token and set JWT token SecurityClient.setSignupToken(''); SecurityClient.setToken(response.token); diff --git a/frontend/src/components/utilities/attemptLogin.ts b/frontend/src/components/utilities/attemptLogin.ts index 08bef1441..90a9876ea 100644 --- a/frontend/src/components/utilities/attemptLogin.ts +++ b/frontend/src/components/utilities/attemptLogin.ts @@ -29,20 +29,17 @@ const attemptLogin = async ( { email, password, - userId, }: { email: string; - userId?: string; password: string; } ): Promise => { - const username = userId ?? email; const telemetry = new Telemetry().getInstance(); return new Promise((resolve, reject) => { client.init( { - username, + username: email, password }, async () => { @@ -51,7 +48,6 @@ const attemptLogin = async ( const { serverPublicKey, salt } = await login1({ email, clientPublicKey, - userId, }); client.setSalt(salt); @@ -72,7 +68,6 @@ const attemptLogin = async ( } = await login2( { email, - userId, clientProof, } ); diff --git a/frontend/src/hooks/useProviderAuth.ts b/frontend/src/hooks/useProviderAuth.ts index 0cb3fb0f7..dc380d7c1 100644 --- a/frontend/src/hooks/useProviderAuth.ts +++ b/frontend/src/hooks/useProviderAuth.ts @@ -31,10 +31,16 @@ export const useProviderAuth = () => { window.addEventListener('storage', handleStorageChange); + if (providerAuthToken) { + const { userId: resultUserId, email: resultEmail } = jwt_decode(providerAuthToken) as any; + setEmail(resultEmail); + setUserId(resultUserId); + } + return () => { window.removeEventListener('storage', handleStorageChange); }; - }); + }, []); return { email, diff --git a/frontend/src/pages/api/auth/CompleteAccountInformationSignup.ts b/frontend/src/pages/api/auth/CompleteAccountInformationSignup.ts index 946505887..302dade1b 100644 --- a/frontend/src/pages/api/auth/CompleteAccountInformationSignup.ts +++ b/frontend/src/pages/api/auth/CompleteAccountInformationSignup.ts @@ -8,6 +8,7 @@ interface Props { protectedKey: string; protectedKeyIV: string; protectedKeyTag: string; + providerAuthToken?: string; publicKey: string; encryptedPrivateKey: string; encryptedPrivateKeyIV: string; @@ -49,9 +50,10 @@ const completeAccountInformationSignup = async ({ encryptedPrivateKeyTag, salt, verifier, - organizationName + organizationName, + providerAuthToken, }: Props) => { - const { data } = await apiRequest.post('/api/v2/signup/complete-account/signup', { + const { data } = await apiRequest.post('/api/v3/signup/complete-account/signup', { email, firstName, lastName, @@ -64,7 +66,8 @@ const completeAccountInformationSignup = async ({ encryptedPrivateKeyTag, salt, verifier, - organizationName + organizationName, + providerAuthToken, }); return data; diff --git a/frontend/src/pages/api/auth/Login1.ts b/frontend/src/pages/api/auth/Login1.ts index 2685a770c..dbf54e37e 100644 --- a/frontend/src/pages/api/auth/Login1.ts +++ b/frontend/src/pages/api/auth/Login1.ts @@ -12,7 +12,6 @@ interface Login1 { const login1 = async (loginDetails: { email: string; clientPublicKey: string; - userId?: string; }) => { const response = await fetch("/api/v2/auth/login1", { method: "POST", diff --git a/frontend/src/pages/api/auth/Login2.ts b/frontend/src/pages/api/auth/Login2.ts index 724a8b1d4..7699d6376 100644 --- a/frontend/src/pages/api/auth/Login2.ts +++ b/frontend/src/pages/api/auth/Login2.ts @@ -20,7 +20,6 @@ interface Login2Response { const login2 = async (loginDetails: { email: string; clientProof: string; - userId?: string; }) => { const response = await fetch('/api/v2/auth/login2', { method: 'POST', diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index 958328b34..bc39198c6 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -25,7 +25,6 @@ export default function Login() { const [isLoginWithEmail, setIsLoginWithEmail] = useState(false); const { providerAuthToken, - userId, email: providerEmail, setProviderAuthToken } = useProviderAuth(); @@ -57,7 +56,6 @@ export default function Login() { if (providerAuthToken && step === 1) { return ( @@ -127,13 +127,14 @@ export default function SignUp() { return ( ) } @@ -142,7 +143,7 @@ export default function SignUp() { return (