diff --git a/src/AuthContext/AuthContext.ts b/src/AuthContext/AuthContext.ts index 7d33820..f712bea 100644 --- a/src/AuthContext/AuthContext.ts +++ b/src/AuthContext/AuthContext.ts @@ -11,13 +11,13 @@ export enum EncryptionStatus { interface AuthContextTypeBase { user: ServerUser | User | null isAuthenticated: boolean - login: (user: ServerUser | User) => void + login: (user: ServerUser | User, callback?: () => void) => void logout: () => void encryptionStatus: EncryptionStatus _decryptUsingMasterPassword: (content: string) => string _encryptUsingMasterPassword: (content: string) => string _decryptUsingPrivateKey: (message: string) => Promise - _setDecryptionPassword: (decryptionPassword: string) => boolean + _setDecryptionPassword: (decryptionPassword: string, callback?: () => void) => boolean _updateUser: (user: ServerUser | User) => void } diff --git a/src/AuthContext/AuthContextProvider.tsx b/src/AuthContext/AuthContextProvider.tsx index be891f3..59c03eb 100644 --- a/src/AuthContext/AuthContextProvider.tsx +++ b/src/AuthContext/AuthContextProvider.tsx @@ -1,6 +1,6 @@ -import {ReactElement, ReactNode, useCallback} from "react" +import {ReactElement, ReactNode, useCallback} from "react" import {useLocalStorage} from "react-use" -import fastHashCode from "fast-hash-code"; +import fastHashCode from "fast-hash-code" import {ServerUser, User} from "~/server-types" @@ -9,7 +9,7 @@ import PasswordShareConfirmationDialog from "./PasswordShareConfirmationDialog" import useContextValue from "./use-context-value" import useExtensionHandler from "./use-extension-handler" import useMasterPassword from "./use-master-password" -import useUser from "./use-user"; +import useUser from "./use-user" export interface AuthContextProviderProps { children: ReactNode @@ -26,9 +26,11 @@ export default function AuthContextProvider({children}: AuthContextProviderProps decryptUsingMasterPassword, decryptUsingPrivateKey, setDecryptionPassword, + decryptionPasswordHash, _masterPassword, logout: logoutMasterPassword, } = useMasterPassword(user || null) + const passwordHash = _masterPassword ? fastHashCode(_masterPassword).toString() : null const {sharePassword, closeDialog, showDialog, dispatchPasswordStatus} = useExtensionHandler( _masterPassword!, user as User, @@ -39,10 +41,11 @@ export default function AuthContextProvider({children}: AuthContextProviderProps }, [logoutMasterPassword]) const contextValue = useContextValue({ - decryptUsingPrivateKey, - encryptUsingMasterPassword, - decryptUsingMasterPassword, - setDecryptionPassword, + _decryptUsingPrivateKey: decryptUsingPrivateKey, + _encryptUsingMasterPassword: encryptUsingMasterPassword, + _decryptUsingMasterPassword: decryptUsingMasterPassword, + _setDecryptionPassword: setDecryptionPassword, + decryptionPasswordHash, logout, login: setUser, user: user || null, @@ -53,7 +56,7 @@ export default function AuthContextProvider({children}: AuthContextProviderProps decryptUsingMasterPassword, user: user || null, updateUser: setUser, - masterPasswordHash: _masterPassword ? fastHashCode(_masterPassword).toString() : null, + masterPasswordHash: passwordHash, }) return ( diff --git a/src/AuthContext/use-context-value.ts b/src/AuthContext/use-context-value.ts index 02af920..4304950 100644 --- a/src/AuthContext/use-context-value.ts +++ b/src/AuthContext/use-context-value.ts @@ -1,49 +1,86 @@ +import {useMemo, useRef} from "react" +import {useUpdateEffect} from "react-use" + import {AuthContextType, EncryptionStatus} from "~/AuthContext/AuthContext" import {ServerUser, User} from "~/server-types" -export interface UseContextValueData { - user: User | ServerUser | null +export type UseContextValueData = Pick< + AuthContextType, + | "user" + | "logout" + | "_encryptUsingMasterPassword" + | "_decryptUsingMasterPassword" + | "_decryptUsingPrivateKey" +> & { + decryptionPasswordHash: string + _setDecryptionPassword: (password: string) => boolean login: (user: User | ServerUser) => void - logout: () => void - encryptUsingMasterPassword: (content: string) => string - decryptUsingMasterPassword: (content: string) => string - decryptUsingPrivateKey: (message: string) => Promise - setDecryptionPassword: (password: string) => boolean } export default function useContextValue({ user, login, logout, - encryptUsingMasterPassword, - decryptUsingMasterPassword, - setDecryptionPassword, - decryptUsingPrivateKey, + _encryptUsingMasterPassword, + _decryptUsingMasterPassword, + _setDecryptionPassword, + _decryptUsingPrivateKey, + decryptionPasswordHash, }: UseContextValueData): AuthContextType { - return { - user, - login, - logout, - isAuthenticated: Boolean(user), - encryptionStatus: (() => { - if (!user) { - return EncryptionStatus.Unavailable - } + const $decryptionPasswordChangeCallback = useRef<(() => void) | null>(null) + const $userChangeCallback = useRef<(() => void) | null>(null) - if (!user.encryptedPassword) { - return EncryptionStatus.Unavailable - } + useUpdateEffect(() => { + $decryptionPasswordChangeCallback.current?.() + }, [decryptionPasswordHash, user]) - if (user.isDecrypted) { - return EncryptionStatus.Available - } + return useMemo( + () => ({ + user, + login: (user, callback) => { + if (callback) { + $userChangeCallback.current = callback + } - return EncryptionStatus.PasswordRequired - })(), - _updateUser: login, - _setDecryptionPassword: setDecryptionPassword, - _encryptUsingMasterPassword: encryptUsingMasterPassword, - _decryptUsingMasterPassword: decryptUsingMasterPassword, - _decryptUsingPrivateKey: decryptUsingPrivateKey, - } + return login(user) + }, + logout, + isAuthenticated: Boolean(user), + encryptionStatus: (() => { + if (!user) { + return EncryptionStatus.Unavailable + } + + if (!user.encryptedPassword) { + return EncryptionStatus.Unavailable + } + + if (user.isDecrypted) { + return EncryptionStatus.Available + } + + return EncryptionStatus.PasswordRequired + })(), + _updateUser: login, + _setDecryptionPassword: (password, callback) => { + if (callback) { + $decryptionPasswordChangeCallback.current = callback + } + + return _setDecryptionPassword(password) + }, + _encryptUsingMasterPassword, + _decryptUsingMasterPassword, + _decryptUsingPrivateKey, + }), + [ + user, + login, + logout, + _setDecryptionPassword, + _encryptUsingMasterPassword, + _decryptUsingMasterPassword, + _decryptUsingPrivateKey, + ], + ) } diff --git a/src/AuthContext/use-master-password.ts b/src/AuthContext/use-master-password.ts index 4613850..58071fc 100644 --- a/src/AuthContext/use-master-password.ts +++ b/src/AuthContext/use-master-password.ts @@ -1,9 +1,10 @@ import {useLocalStorage} from "react-use" import {useCallback, useMemo} from "react" -import {decrypt, readMessage, readPrivateKey} from "openpgp"; +import {decrypt, readMessage, readPrivateKey} from "openpgp" +import fastHashCode from "fast-hash-code" import {decryptString, encryptString} from "~/utils" -import {ServerUser, User} from "~/server-types"; +import {ServerUser, User} from "~/server-types" export interface UseMasterPasswordResult { encryptUsingMasterPassword: (content: string) => string @@ -12,13 +13,12 @@ export interface UseMasterPasswordResult { setDecryptionPassword: (password: string) => boolean logout: () => void + decryptionPasswordHash: string // Use this cautiously _masterPassword: string } -export default function useMasterPassword( - user: User | ServerUser | null, -): UseMasterPasswordResult { +export default function useMasterPassword(user: User | ServerUser | null): UseMasterPasswordResult { const [decryptionPassword, setDecryptionPassword] = useLocalStorage( "_global-context-auth-decryption-password", null, @@ -78,21 +78,23 @@ export default function useMasterPassword( [user], ) - const updateDecryptionPassword = useCallback((password: string) => { - if (!user || !user.encryptedPassword) { - throw new Error("User not set.") - } + const updateDecryptionPassword = useCallback( + (password: string) => { + if (user?.encryptedPassword) { + try { + const masterPassword = decryptString(user.encryptedPassword, password) + JSON.parse(decryptString((user as ServerUser).encryptedNotes, masterPassword)) + } catch (e) { + return false + } + } - try { - const masterPassword = decryptString(user.encryptedPassword, password) - JSON.parse(decryptString((user as ServerUser).encryptedNotes, masterPassword)) setDecryptionPassword(password) - } catch { - return false; - } - return true; - }, [user, masterPassword]) + return true + }, + [user, masterPassword], + ) const logout = useCallback(() => { setDecryptionPassword(null) @@ -105,5 +107,6 @@ export default function useMasterPassword( logout, setDecryptionPassword: updateDecryptionPassword, _masterPassword: masterPassword!, + decryptionPasswordHash: fastHashCode(decryptionPassword || "").toString(), } } diff --git a/src/AuthContext/use-user.ts b/src/AuthContext/use-user.ts index 7aff447..b9e44c4 100644 --- a/src/AuthContext/use-user.ts +++ b/src/AuthContext/use-user.ts @@ -1,77 +1,74 @@ -import {Dispatch, SetStateAction, useEffect} from "react"; -import {AxiosError} from "axios"; +import {Dispatch, SetStateAction, useEffect} from "react" +import {AxiosError} from "axios" -import {useMutation, useQuery} from "@tanstack/react-query"; +import {useMutation, useQuery} from "@tanstack/react-query" import {REFRESH_TOKEN_URL, RefreshTokenResult, getMe, refreshToken} from "~/apis" -import {AuthenticationDetails, ServerUser, User} from "~/server-types"; -import {client} from "~/constants/axios-client"; +import {AuthenticationDetails, ServerUser, User} from "~/server-types" +import {client} from "~/constants/axios-client" export interface UseAuthData { - logout: () => void - masterPasswordHash: string | null - decryptUsingMasterPassword: (content: string) => string - user: User | ServerUser | null - updateUser: Dispatch> + logout: () => void + masterPasswordHash: string | null + decryptUsingMasterPassword: (content: string) => string + user: User | ServerUser | null + updateUser: Dispatch> } export default function useUser({ - logout, - masterPasswordHash, - decryptUsingMasterPassword, - user, - updateUser, + logout, + masterPasswordHash, + decryptUsingMasterPassword, + user, + updateUser, }: UseAuthData) { - const {mutateAsync: refresh} = useMutation(refreshToken, { - onError: () => logout(), - }) + const {mutateAsync: refresh} = useMutation(refreshToken, { + onError: () => logout(), + }) - useQuery(["get_me"], getMe, { - refetchOnWindowFocus: "always", - refetchOnReconnect: "always", - retry: 2, - enabled: user !== null, - }) + useQuery(["get_me"], getMe, { + refetchOnWindowFocus: "always", + refetchOnReconnect: "always", + retry: 2, + enabled: user !== null, + }) - // Decrypt user notes - useEffect(() => { - if (user && !user.isDecrypted && user.encryptedPassword && masterPasswordHash) { - const note = JSON.parse(decryptUsingMasterPassword(user.encryptedNotes!)) + // Decrypt user notes + useEffect(() => { + if (user && !user.isDecrypted && user.encryptedPassword && masterPasswordHash) { + const note = JSON.parse(decryptUsingMasterPassword(user.encryptedNotes!)) - updateUser( - prevUser => - ({ - ...(prevUser || {}), - notes: note, - isDecrypted: true, - } as User), - ) - } - }, [user, decryptUsingMasterPassword, updateUser, masterPasswordHash]) + updateUser({ + ...user, + notes: note, + isDecrypted: true, + } as User) + } + }, [user, decryptUsingMasterPassword, updateUser, masterPasswordHash]) - // Refresh token and logout user if needed - useEffect(() => { - const interceptor = client.interceptors.response.use( - response => response, - async (error: AxiosError) => { - if (error.isAxiosError) { - if (error.response?.status === 401) { - // Check if error comes from refreshing the token. - // If yes, the user has been logged out completely. - const request: XMLHttpRequest = error.request + // Refresh token and logout user if needed + useEffect(() => { + const interceptor = client.interceptors.response.use( + response => response, + async (error: AxiosError) => { + if (error.isAxiosError) { + if (error.response?.status === 401) { + // Check if error comes from refreshing the token. + // If yes, the user has been logged out completely. + const request: XMLHttpRequest = error.request - if (request.responseURL === REFRESH_TOKEN_URL) { - await logout() - } else { - await refresh() - } - } - } + if (request.responseURL === REFRESH_TOKEN_URL) { + await logout() + } else { + await refresh() + } + } + } - throw error - }, - ) + throw error + }, + ) - return () => client.interceptors.response.eject(interceptor) - }, [logout, refresh]) + return () => client.interceptors.response.eject(interceptor) + }, [logout, refresh]) } diff --git a/src/route-widgets/CompleteAccountRoute/PasswordForm.tsx b/src/route-widgets/CompleteAccountRoute/PasswordForm.tsx index 7ff1845..e1ff429 100644 --- a/src/route-widgets/CompleteAccountRoute/PasswordForm.tsx +++ b/src/route-widgets/CompleteAccountRoute/PasswordForm.tsx @@ -12,7 +12,7 @@ import passwordGenerator from "secure-random-password" import {PasswordField, SimpleForm} from "~/components" import {buildEncryptionPassword, encryptString} from "~/utils" import {isDev} from "~/constants/development" -import {useExtensionHandler, useSystemPreferredTheme, useUser} from "~/hooks" +import {useExtensionHandler, useNavigateToNext, useSystemPreferredTheme, useUser} from "~/hooks" import {MASTER_PASSWORD_LENGTH} from "~/constants/values" import {AuthenticationDetails, UserNote} from "~/server-types" import {UpdateAccountData, updateAccount} from "~/apis" @@ -34,6 +34,7 @@ export default function PasswordForm({onDone}: PasswordFormProps): ReactElement const user = useUser() const theme = useSystemPreferredTheme() + const navigateToNext = useNavigateToNext() const $password = useRef(null) const $passwordConfirmation = useRef(null) const schema = yup.object().shape({ @@ -65,12 +66,6 @@ export default function PasswordForm({onDone}: PasswordFormProps): ReactElement ) const {mutateAsync} = useMutation( updateAccount, - { - onSuccess: ({user}) => { - login(user) - setTimeout(onDone, 0) - }, - }, ) const formik = useFormik
({ validationSchema: schema, @@ -97,19 +92,25 @@ export default function PasswordForm({onDone}: PasswordFormProps): ReactElement } const encryptedNotes = encryptUserNote(note, masterPassword) - _setDecryptionPassword(encryptionPassword) - - await mutateAsync({ - encryptedPassword: encryptedMasterPassword, - publicKey: ( - await readKey({ - armoredKey: keyPair.publicKey, - }) - ) - .toPublic() - .armor(), - encryptedNotes, - }) + await mutateAsync( + { + encryptedPassword: encryptedMasterPassword, + publicKey: ( + await readKey({ + armoredKey: keyPair.publicKey, + }) + ) + .toPublic() + .armor(), + encryptedNotes, + }, + { + onSuccess: ({user: newUser}) => { + login(newUser) + _setDecryptionPassword(encryptionPassword, navigateToNext) + }, + }, + ) } catch (error) { setErrors({detail: t("general.defaultError")}) } diff --git a/src/route-widgets/LoginRoute/ConfirmCodeForm/ConfirmCodeForm.tsx b/src/route-widgets/LoginRoute/ConfirmCodeForm/ConfirmCodeForm.tsx index fd56241..35aec05 100644 --- a/src/route-widgets/LoginRoute/ConfirmCodeForm/ConfirmCodeForm.tsx +++ b/src/route-widgets/LoginRoute/ConfirmCodeForm/ConfirmCodeForm.tsx @@ -57,7 +57,7 @@ export default function ConfirmCodeForm({ sameRequestToken, }: ConfirmCodeFormProps): ReactElement { const settings = useLoaderData() as ServerSettings - const expirationTime = isDev ? 9 : settings.emailLoginExpirationInSeconds + const expirationTime = isDev ? 70 : settings.emailLoginExpirationInSeconds const {t} = useTranslation() const requestDate = useMemo(() => new Date(), []) const [isExpiringSoon, setIsExpiringSoon] = useState(false) diff --git a/src/routes/EnterDecryptionPassword.tsx b/src/routes/EnterDecryptionPassword.tsx index 29e63c6..31a9dc2 100644 --- a/src/routes/EnterDecryptionPassword.tsx +++ b/src/routes/EnterDecryptionPassword.tsx @@ -36,15 +36,14 @@ export default function EnterDecryptionPassword(): ReactElement { onSubmit: async ({password}, {setErrors}) => { const decryptionPassword = buildEncryptionPassword(password, user.email.address) - console.log("decryptionPassword", decryptionPassword) - if (!_setDecryptionPassword(decryptionPassword)) { + const isPasswordCorrect = _setDecryptionPassword(decryptionPassword, navigateToNext) + + if (!isPasswordCorrect) { setErrors({ password: t( "components.EnterDecryptionPassword.form.password.errors.invalidPassword", ), }) - } else { - setTimeout(navigateToNext, 0) } }, })