added AuthContext; fixed signing up; improvements & bugfixes

This commit is contained in:
Myzel394 2022-10-20 00:27:25 +02:00
parent ade221be7d
commit bb4c58508d
19 changed files with 171 additions and 81 deletions

View File

@ -30,6 +30,7 @@
"react-use": "^17.4.0",
"secure-random-password": "^0.2.3",
"ua-parser-js": "^1.0.2",
"use-system-theme": "^0.1.1",
"yup": "^0.32.11"
},
"devDependencies": {

View File

@ -1,19 +1,19 @@
import {createContext} from "react"
import {User} from "~/server-types"
import {ServerUser, User} from "~/server-types"
interface AuthContextTypeBase {
user: User | null
user: ServerUser | User | null
isAuthenticated: boolean
login: (user: User, callback: () => void) => Promise<void>
login: (user: ServerUser, callback?: () => void) => Promise<void>
logout: () => void
_decryptContent: (content: string) => string
_encryptContent: (content: string) => string
_setMasterPassword: (masterPassword: string) => void
_setDecryptionPassword: (decryptionPassword: string) => void
}
interface AuthContextTypeAuthenticated {
user: User
user: ServerUser
isAuthenticated: true
}
@ -41,8 +41,8 @@ const AuthContext = createContext<AuthContextType>({
_encryptContent: () => {
throw new Error("_encryptContent() not implemented")
},
_setMasterPassword: () => {
throw new Error("_setMasterPassword() not implemented")
_setDecryptionPassword: () => {
throw new Error("_setMasterDecryptionPassword() not implemented")
},
})

View File

@ -1,17 +1,10 @@
import {
ReactElement,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react"
import {ReactElement, ReactNode, useCallback, useEffect, useMemo} from "react"
import {useLocalStorage} from "react-use"
import {AxiosError} from "axios"
import {useMutation} from "@tanstack/react-query"
import {User} from "~/server-types"
import {ServerUser, User} from "~/server-types"
import {
REFRESH_TOKEN_URL,
RefreshTokenResult,
@ -30,11 +23,20 @@ export interface AuthContextProviderProps {
export default function AuthContextProvider({
children,
}: AuthContextProviderProps): ReactElement {
const [masterPassword, setMasterPassword] = useState<string | null>(null)
const [user, setUser] = useLocalStorage<User | null>(
const [decryptionPassword, setDecryptionPassword] = useLocalStorage<
string | null
>("_global-context-auth-decryption-password", null)
const [user, setUser] = useLocalStorage<ServerUser | User | null>(
"_global-context-auth-user",
null,
)
const masterPassword = useMemo<string | null>(() => {
if (decryptionPassword === null || !user?.encryptedPassword) {
return null
}
return decryptString(user!.encryptedPassword, decryptionPassword!)
}, [user?.encryptedPassword, decryptionPassword])
const logout = useCallback(async (forceLogout = true) => {
setUser(null)
@ -44,12 +46,6 @@ export default function AuthContextProvider({
}
}, [])
const login = useCallback(async (user: User, callback?: () => void) => {
setUser(user)
callback?.()
}, [])
const encryptContent = useCallback(
(content: string) => {
if (!masterPassword) {
@ -72,6 +68,27 @@ export default function AuthContextProvider({
[masterPassword],
)
const login = useCallback(
async (user: ServerUser, callback?: () => void) => {
if (masterPassword !== null && user.encryptedNotes) {
const note = JSON.parse(decryptContent(user.encryptedNotes))
const newUser: User = {
...user,
notes: note,
isDecrypted: true,
}
setUser(newUser)
} else {
setUser(user)
}
callback?.()
},
[masterPassword, decryptContent],
)
const {mutateAsync: refresh} = useMutation<
RefreshTokenResult,
AxiosError,
@ -86,9 +103,9 @@ export default function AuthContextProvider({
login,
logout,
isAuthenticated: user !== null,
_setMasterPassword: setMasterPassword,
_encryptContent: encryptContent,
_decryptContent: decryptContent,
_setDecryptionPassword: setDecryptionPassword,
}),
[refresh, login, logout],
)

View File

@ -41,6 +41,9 @@ export default async function createAlias(
const {data} = await client.post(
`${import.meta.env.VITE_SERVER_BASE_URL}/alias`,
aliasData,
{
withCredentials: true,
},
)
return data

View File

@ -4,6 +4,9 @@ import {client} from "~/constants/axios-client"
export default async function getAliases(): Promise<Array<Alias>> {
const {data} = await client.get(
`${import.meta.env.VITE_SERVER_BASE_URL}/alias`,
{
withCredentials: true,
},
)
return data

View File

@ -1,8 +1,9 @@
import {User} from "~/server-types"
import {ServerUser} from "~/server-types"
export default function parseUser(user: any): User {
export default function parseUser(user: any): ServerUser {
return {
...user,
isDecrypted: false,
createdAt: new Date(user.createdAt),
}
}

View File

@ -1,8 +1,8 @@
import {User} from "~/server-types"
import {ServerUser} from "~/server-types"
import {client} from "~/constants/axios-client"
export interface RefreshTokenResult {
user: User
user: ServerUser
detail: string
}

View File

@ -1,7 +1,7 @@
import {client} from "~/constants/axios-client"
export interface SignupResult {
normalized_email: string
normalizedEmail: string
}
export default async function signup(email: string): Promise<SignupResult> {

View File

@ -1,20 +1,23 @@
import {Language} from "~/server-types"
import {AuthenticationDetails, Language} from "~/server-types"
import {client} from "~/constants/axios-client"
import parseUser from "~/apis/helpers/parse-user"
export interface UpdateAccountData {
password: string
publicKey: string
encryptedPrivateKey: string
language: Language
encryptedPassword?: string
encryptedNotes?: string
publicKey?: string
language?: Language
}
export default async function updateAccount(
updateData: Partial<UpdateAccountData>,
): Promise<void> {
updateData: UpdateAccountData,
): Promise<AuthenticationDetails> {
const {data} = await client.patch(
`${import.meta.env.VITE_SERVER_BASE_URL}/account`,
updateData,
{
withCredentials: true,
},
)
return {

View File

@ -17,8 +17,10 @@ export default async function verifyEmail({
email: email,
token: token,
},
{
withCredentials: true,
},
)
console.log(data)
return {
...data,

View File

@ -4,3 +4,5 @@ export * from "./use-query-params"
export {default as useQueryParams} from "./use-query-params"
export * from "./use-user"
export {default as useUser} from "./use-user"
export * from "./use-system-preferred-theme"
export {default as useSystemPreferredTheme} from "./use-system-preferred-theme"

View File

@ -0,0 +1,9 @@
import {useMedia} from "react-use"
import {Theme} from "~/server-types"
export default function useSystemPreferredTheme(): Theme {
const prefersDark = useMedia("(prefers-color-scheme: dark)")
return prefersDark ? Theme.DARK : Theme.LIGHT
}

View File

@ -1,20 +1,30 @@
import {useNavigate} from "react-router-dom"
import {useLocation, useNavigate} from "react-router-dom"
import {useContext, useLayoutEffect} from "react"
import {User} from "~/server-types"
import {ServerUser} from "~/server-types"
import AuthContext from "~/AuthContext/AuthContext"
const AUTHENTICATION_PATHS = [
"/auth/login",
"/auth/signup",
"/auth/complete-account",
]
/// Returns the currently authenticated user.
// If the user is not authenticated, it will automatically redirect to the login page.
export default function useUser(): User {
export default function useUser(): ServerUser {
const location = useLocation()
const navigate = useNavigate()
const {user, isAuthenticated} = useContext(AuthContext)
useLayoutEffect(() => {
if (!isAuthenticated) {
if (
!isAuthenticated &&
!AUTHENTICATION_PATHS.includes(location.pathname)
) {
navigate("/auth/login")
}
}, [isAuthenticated, navigate])
return user as User
return user as ServerUser
}

View File

@ -1,8 +1,8 @@
import * as yup from "yup"
import {useFormik} from "formik"
import {MdCheckCircle, MdChevronRight, MdLock} from "react-icons/md"
import {generateKey} from "openpgp"
import React, {ReactElement, useMemo} from "react"
import {generateKey, readKey} from "openpgp"
import React, {ReactElement, useContext, useMemo} from "react"
import passwordGenerator from "secure-random-password"
import {LoadingButton} from "@mui/lab"
@ -12,8 +12,14 @@ import {useMutation} from "@tanstack/react-query"
import {PasswordField} from "~/components"
import {buildEncryptionPassword, encryptString} from "~/utils"
import {isDev} from "~/constants/development"
import {useUser} from "~/hooks"
import {useSystemPreferredTheme, useUser} from "~/hooks"
import {MASTER_PASSWORD_LENGTH} from "~/constants/values"
import {AuthenticationDetails, UserNote} from "~/server-types"
import {AxiosError} from "axios"
import {UpdateAccountData, updateAccount} from "~/apis"
import {encryptUserNote} from "~/utils/encrypt-user-note"
import {useNavigate} from "react-router-dom"
import AuthContext from "~/AuthContext/AuthContext"
interface Form {
password: string
@ -31,19 +37,32 @@ const schema = yup.object().shape({
export default function PasswordForm(): ReactElement {
const user = useUser()
const {} = useMutation<>()
const theme = useSystemPreferredTheme()
const navigate = useNavigate()
const {_setDecryptionPassword, login} = useContext(AuthContext)
const awaitGenerateKey = useMemo(
() =>
generateKey({
type: "rsa",
format: "armored",
curve: "curve25519",
userIDs: [{name: "John Smith", email: "john@example.com"}],
passphrase: "",
rsaBits: isDev ? 2048 : 4096,
}),
[],
)
const {mutateAsync} = useMutation<
AuthenticationDetails,
AxiosError,
UpdateAccountData
>(updateAccount, {
onSuccess: ({user}) => {
login(user)
navigate("/")
},
})
const formik = useFormik<Form>({
validationSchema: schema,
initialValues: {
@ -64,12 +83,27 @@ export default function PasswordForm(): ReactElement {
)
const encryptedMasterPassword = encryptString(
masterPassword,
`${values.password}-${user.email.address}`,
)
const encryptedPrivateKey = encryptString(
keyPair.privateKey,
masterPassword,
encryptionPassword,
)
const note: UserNote = {
theme,
privateKey: keyPair.privateKey,
}
const encryptedNotes = encryptUserNote(note, masterPassword)
_setDecryptionPassword(values.password)
await mutateAsync({
encryptedPassword: encryptedMasterPassword,
publicKey: (
await readKey({
armoredKey: keyPair.publicKey,
})
)
.toPublic()
.armor(),
encryptedNotes,
})
} catch (error) {
setErrors({detail: "An error occurred"})
}

View File

@ -35,7 +35,7 @@ export default function EmailForm({
const {mutateAsync} = useMutation<SignupResult, AxiosError, string>(
signup,
{
onSuccess: ({normalized_email}) => onSignUp(normalized_email),
onSuccess: ({normalizedEmail}) => onSignUp(normalizedEmail),
},
)
const formik = useFormik<Form>({
@ -112,9 +112,9 @@ export default function EmailForm({
</SimpleForm>
</form>
</MultiStepFormElement>
{!serverSettings.other_relays_enabled && (
{!serverSettings.otherRelaysEnabled && (
<DetectEmailAutofillService
domains={serverSettings.other_relay_domains}
domains={serverSettings.otherRelayDomains}
/>
)}
</>

View File

@ -28,14 +28,14 @@ export default function VerifyEmailRoute(): ReactElement {
const tokenSchema = yup
.string()
.length(serverSettings.email_verification_length)
.length(serverSettings.emailVerificationLength)
.test("token", "Invalid token", token => {
if (!token) {
return false
}
// Check token only contains chars from `serverSettings.email_verification_chars`
const chars = serverSettings.email_verification_chars.split("")
const chars = serverSettings.emailVerificationChars.split("")
return token.split("").every(char => chars.includes(char))
})

View File

@ -27,9 +27,18 @@ export enum Theme {
DARK = "dark",
}
export interface User {
export enum ThemeSettings {
LIGHT = "light",
DARK = "dark",
SYSTEM = "system",
}
export interface ServerUser {
id: string
createdAt: Date
encryptedNotes: string
isDecrypted: false
encryptedPassword: string
email: {
address: string
isVerified: boolean
@ -44,21 +53,21 @@ export interface User {
}
export interface AuthenticationDetails {
user: User
user: ServerUser
detail: string
}
export interface ServerSettings {
mail_domain: string
random_email_id_min_length: number
random_email_id_chars: string
image_proxy_enabled: boolean
image_proxy_life_time: number
disposable_emails_enabled: boolean
other_relays_enabled: boolean
other_relay_domains: Array<string>
email_verification_chars: string
email_verification_length: number
mailDomain: string
randomEmailIdMinLength: number
RandomEmailIdChars: string
imageProxyEnabled: boolean
imageProxyLifeTime: number
disposableEmailsEnabled: boolean
otherRelaysEnabled: boolean
otherRelayDomains: Array<string>
emailVerificationChars: string
emailVerificationLength: number
}
export interface MinimumServerResponse {
@ -82,3 +91,9 @@ export interface UserNote {
theme: Theme
privateKey: string
}
export interface User
extends Omit<ServerUser, "encryptedNotes" | "isDecrypted"> {
notes: UserNote
isDecrypted: true
}

View File

@ -1,5 +1,5 @@
import Crypto from "crypto-js"
export default function decryptString(value: string, password: string): string {
return Crypto.AES.decrypt(value, password).toString(Crypto.enc.Utf8)
return Crypto.AES.decrypt(value, password).toString()
}

View File

@ -10,16 +10,6 @@ export const USER_NOTE_SCHEMA = yup.object().shape({
theme: yup.string().oneOf(Object.values(Theme)).required(),
})
export function createUserNote(
privateKey: string,
theme: Theme = Theme.LIGHT,
): UserNote {
return {
theme,
privateKey,
}
}
export function decryptUserNote(
encryptedUserNote: string,
password: string,