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", "react-use": "^17.4.0",
"secure-random-password": "^0.2.3", "secure-random-password": "^0.2.3",
"ua-parser-js": "^1.0.2", "ua-parser-js": "^1.0.2",
"use-system-theme": "^0.1.1",
"yup": "^0.32.11" "yup": "^0.32.11"
}, },
"devDependencies": { "devDependencies": {

View File

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

View File

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

View File

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

View File

@ -4,6 +4,9 @@ import {client} from "~/constants/axios-client"
export default async function getAliases(): Promise<Array<Alias>> { export default async function getAliases(): Promise<Array<Alias>> {
const {data} = await client.get( const {data} = await client.get(
`${import.meta.env.VITE_SERVER_BASE_URL}/alias`, `${import.meta.env.VITE_SERVER_BASE_URL}/alias`,
{
withCredentials: true,
},
) )
return data 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 { return {
...user, ...user,
isDecrypted: false,
createdAt: new Date(user.createdAt), 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" import {client} from "~/constants/axios-client"
export interface RefreshTokenResult { export interface RefreshTokenResult {
user: User user: ServerUser
detail: string detail: string
} }

View File

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

View File

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

View File

@ -4,3 +4,5 @@ export * from "./use-query-params"
export {default as useQueryParams} from "./use-query-params" export {default as useQueryParams} from "./use-query-params"
export * from "./use-user" export * from "./use-user"
export {default as useUser} 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 {useContext, useLayoutEffect} from "react"
import {User} from "~/server-types" import {ServerUser} from "~/server-types"
import AuthContext from "~/AuthContext/AuthContext" import AuthContext from "~/AuthContext/AuthContext"
const AUTHENTICATION_PATHS = [
"/auth/login",
"/auth/signup",
"/auth/complete-account",
]
/// Returns the currently authenticated user. /// Returns the currently authenticated user.
// If the user is not authenticated, it will automatically redirect to the login page. // 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 navigate = useNavigate()
const {user, isAuthenticated} = useContext(AuthContext) const {user, isAuthenticated} = useContext(AuthContext)
useLayoutEffect(() => { useLayoutEffect(() => {
if (!isAuthenticated) { if (
!isAuthenticated &&
!AUTHENTICATION_PATHS.includes(location.pathname)
) {
navigate("/auth/login") navigate("/auth/login")
} }
}, [isAuthenticated, navigate]) }, [isAuthenticated, navigate])
return user as User return user as ServerUser
} }

View File

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

View File

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

View File

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

View File

@ -27,9 +27,18 @@ export enum Theme {
DARK = "dark", DARK = "dark",
} }
export interface User { export enum ThemeSettings {
LIGHT = "light",
DARK = "dark",
SYSTEM = "system",
}
export interface ServerUser {
id: string id: string
createdAt: Date createdAt: Date
encryptedNotes: string
isDecrypted: false
encryptedPassword: string
email: { email: {
address: string address: string
isVerified: boolean isVerified: boolean
@ -44,21 +53,21 @@ export interface User {
} }
export interface AuthenticationDetails { export interface AuthenticationDetails {
user: User user: ServerUser
detail: string detail: string
} }
export interface ServerSettings { export interface ServerSettings {
mail_domain: string mailDomain: string
random_email_id_min_length: number randomEmailIdMinLength: number
random_email_id_chars: string RandomEmailIdChars: string
image_proxy_enabled: boolean imageProxyEnabled: boolean
image_proxy_life_time: number imageProxyLifeTime: number
disposable_emails_enabled: boolean disposableEmailsEnabled: boolean
other_relays_enabled: boolean otherRelaysEnabled: boolean
other_relay_domains: Array<string> otherRelayDomains: Array<string>
email_verification_chars: string emailVerificationChars: string
email_verification_length: number emailVerificationLength: number
} }
export interface MinimumServerResponse { export interface MinimumServerResponse {
@ -82,3 +91,9 @@ export interface UserNote {
theme: Theme theme: Theme
privateKey: string 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" import Crypto from "crypto-js"
export default function decryptString(value: string, password: string): string { 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(), theme: yup.string().oneOf(Object.values(Theme)).required(),
}) })
export function createUserNote(
privateKey: string,
theme: Theme = Theme.LIGHT,
): UserNote {
return {
theme,
privateKey,
}
}
export function decryptUserNote( export function decryptUserNote(
encryptedUserNote: string, encryptedUserNote: string,
password: string, password: string,