Skip to content

Commit

Permalink
Merge pull request #8 from activescott/feat/edit-user
Browse files Browse the repository at this point in the history
Feat/edit user
  • Loading branch information
activescott committed Feb 22, 2021
2 parents 627bba9 + d006f1e commit df4b20f
Show file tree
Hide file tree
Showing 23 changed files with 1,228 additions and 171 deletions.
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,25 +100,27 @@ Some super helpful references to keep handy:

- [+] feat: logout endpoint (clears the session)

- [ ] feat: extract lambda/middleware into new package (@web-app-stack/lambda-auth)
- [+] feat: CSRF token middleware in all state-changing APIs:

- [ ] feat: CSRF token middleware in all state-changing APIs:
- [+] CSRF server support: automatic detection/rejection (see requireCsrfHandlerFactory)
- [+] CSRF client support: Automatic inclusion of the token (see fetchWithCsrf)

- [ ] CSRF server support: automatic detection/rejection
- [+] CSRF client support: Automatic inclusion of the token
- [+] feat: ability to delete current user's linked identity
- [+] feat: ability to delete current user

- UserContext:

- [+] feat: UserContext available as a react context so that client side app always has access to user/auth when authenticated (see alert genie, but no need for auth0)
- [+] feat: all local API requests in `client/src/lib/useApiHooks.ts` use accessToken
- [ ] feat: login/logout pages
- [ ] feat: Avatar and login/logout/profile stuff in header
- [+] feat: login/logout pages
- [+] feat: Avatar and login/logout/profile stuff in header

- [+] chore: upgrade architect
- [ ] feat: extract lambda/middleware into new package (@web-app-stack/lambda-auth)
- [ ] chore: basic unit tests (the server is thoroughly tested with unit tests, the client no-so-much)
- [ ] chore: git hooks for linting
- [ ] chore: git hooks for unit tests
- [ ] chore: move useApiHooks and ~~useScript hooks~~ into new package @activescott/react-hooks
- [ ] chore: move useApiHooks and ~~useScript hooks~~ into new package @activescott/react-hooks?

### Future

Expand Down
29 changes: 29 additions & 0 deletions client/src/components/alert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from "react"

interface Props {
children: React.ReactNode
}

/**
* A Bootstrap Alert https://getbootstrap.com/docs/4.6/components/alerts/
*/
const Alert = (props: Props): JSX.Element => {
return (
<div
className="alert alert-warning alert-dismissible fade show"
role="alert"
>
{props.children}
<button
type="button"
className="close"
data-dismiss="alert"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
)
}

export default Alert
89 changes: 73 additions & 16 deletions client/src/components/auth/UserProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import React, { useContext, useEffect, useState } from "react"
import { fetchJson } from "../../lib/fetch"
import { fetchJson, fetchWithCsrf } from "../../lib/fetch"
import { doLogin, doLogout } from "./authUtil"

type ApiIdentity = {
id: string
provider: string
sub: string
}

/** Defines the attributes of an authenticated user. */
export interface AuthUser {
/** The unique identifier for the user (the subject). */
Expand All @@ -12,6 +18,8 @@ export interface AuthUser {
email?: string
/** If specified, specifies the name of the user. */
name?: string
/** The list of identities for this user at different authentication providers. */
identities: ApiIdentity[]
}

export interface ProvidedUserContext {
Expand All @@ -20,6 +28,10 @@ export interface ProvidedUserContext {
isLoading: boolean
login: (providerName: string) => Promise<void>
logout: () => Promise<void>
/** Deletes and effectively unlinks the specified identity from this user. Get the identityID from @see AuthUser.identities . */
deleteIdentity: (identityID: string) => Promise<void>
/** Deletes the current user's profile and logs them out */
deleteUser: () => Promise<void>
// TODO: add the below login/logout/token implementations:
// getAccessToken: (options?: AccessTokenOptions) => Promise<string>
}
Expand All @@ -34,6 +46,10 @@ const DefaultUserContext: ProvidedUserContext = {
logout: async () => {
doLogout()
},
deleteIdentity: async (): Promise<void> =>
Promise.reject(new Error("UserContext not yet initialized")),
deleteUser: async (): Promise<void> =>
Promise.reject(new Error("UserContext not yet initialized")),
}

const UserContext = React.createContext<ProvidedUserContext>(DefaultUserContext)
Expand All @@ -47,22 +63,23 @@ export const UserProvider = (props: Props): JSX.Element => {
const [user, setUser] = useState<AuthUser | null>(null)
const [isLoading, setIsLoading] = useState(true)

useEffect(() => {
async function go(): Promise<void> {
try {
// see if this user is authenticated by calling the UserInfo endpoint
const userInfo = await fetchJson<AuthUser>(
`${process.env.PUBLIC_URL}/auth/me`
)
setUser(userInfo)
} catch (e) {
// TODO: fix fetchJson so we can get the response code and get error body (AuthUser | AuthError).
// eslint-disable-next-line no-console
console.error("Failed to authenticate user: " + e.toString())
}
setIsLoading(false)
async function reloadUser(): Promise<void> {
try {
// see if this user is authenticated by calling the UserInfo endpoint
const userInfo = await fetchJson<AuthUser>(
`${process.env.PUBLIC_URL}/auth/me`
)
setUser(userInfo)
} catch (e) {
// TODO: fix fetchJson so we can get the response code and get error body (AuthUser | AuthError).
// eslint-disable-next-line no-console
console.warn("Failed to authenticate user: " + e.toString())
}
go()
setIsLoading(false)
}

useEffect(() => {
reloadUser()
}, [])

return (
Expand All @@ -73,6 +90,46 @@ export const UserProvider = (props: Props): JSX.Element => {
isLoading,
login: DefaultUserContext.login,
logout: DefaultUserContext.logout,
deleteIdentity: async (identityID: string): Promise<void> => {
const createDeleteIdentityUrl = (identityId: string): string => {
const encodedID = encodeURIComponent(identityId)
return `${process.env.PUBLIC_URL}/auth/me/identities/${encodedID}`
}

const response = await fetchWithCsrf(
createDeleteIdentityUrl(identityID),
{
method: "DELETE",
}
)
if (!response.ok) {
// eslint-disable-next-line no-console
console.error(
"deleteIdentity request failed: ",
response.status,
response.statusText
)
}
// NOTE: no need to await this reloadUser as it will update setState methods when it's finished
reloadUser()
},
deleteUser: async (): Promise<void> => {
const response = await fetchWithCsrf(
`${process.env.PUBLIC_URL}/auth/me/`,
{
method: "DELETE",
}
)
if (!response.ok) {
// eslint-disable-next-line no-console
console.error(
"deleteUser request failed: ",
response.status,
response.statusText
)
}
DefaultUserContext.logout()
},
}}
>
{props.children}
Expand Down
18 changes: 18 additions & 0 deletions client/src/lib/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,21 @@ export async function fetchText(
)
}
}

/**
* Performs a standard fetch after requesting a CSRF token and adding it to the headers
*/
export async function fetchWithCsrf(
url: string,
init: RequestInit
): Promise<Response> {
const token = await fetchText(`${process.env.PUBLIC_URL}/auth/csrf`)
init = {
...init,
headers: {
...(init.headers || {}),
"x-csrf-token": token,
},
}
return fetch(url, init)
}
141 changes: 91 additions & 50 deletions client/src/lib/useApiHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export type ApiResponseHookResult<TResponseData> = [

/**
* A hook to use an API response from the local backend API.
* @param initialUrl The url to fetch. This is a RELATIVE path for the local backend API.
* @param initialUrl The url to fetch.
* @param initialApiResponse The initial response you want to use until the API responds.
* @returns
*/
Expand Down Expand Up @@ -59,63 +59,98 @@ export const useApiGet = <TData>(
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ApiPostData = any
type ApiBodyData = any

export const CSRF_HEADER_NAME = "X-CSRF-TOKEN"

// TODO: Refactor useApiPost to a common method and add useApiPut, useApiPatch, useApiUpdate, useApiDelete, etc.
/**
* A hook to use an API response from the local backend API.
* @param initialUrl The url to fetch. This is a RELATIVE path for the local backend API.
* @param initialApiResponse The initial response you want to use until the API responds.
* Request to the local backend API.
* @param initialUrl The initial Url to request
* @param initialApiResponse The initial/default response before you get a full response from the server
* @param requestBody The body to send to the server
*/
export const useApiPost = <TData>(
export function useApiPost<TResponseData>(
initialUrl: string,
initialApiResponse: TData,
postBody: ApiPostData
): ApiResponseHookResult<TData> => {
const [url, setUrl] = useState(initialUrl)
const [response, setResponse] = useState(initialApiResponse)
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const csrfToken = useCsrfToken()
initialApiResponse: TResponseData,
requestBody: ApiBodyData
): ApiResponseHookResult<TResponseData> {
// NOTE: using the verbose function definition here for better documentation/intellisense
const hookImp = createApiHook<TResponseData>("POST", true)
return hookImp(initialUrl, initialApiResponse, requestBody)
}

useEffect(() => {
async function fetchApi(): Promise<void> {
if (!csrfToken) {
// eslint-disable-next-line no-console
console.error("No csrf token but useApiPost requires it")
return
}
try {
setIsLoading(true)
const rawData = await fetchJson<TData>(url, {
body: JSON.stringify(postBody),
method: "post",
headers: {
CSRF_HEADER_NAME: await csrfToken,
},
})
setResponse(rawData)
setIsError(false)
} catch (reason) {
// eslint-disable-next-line no-console
console.error(`Error posting to ${url}:`, reason)
setIsError(true)
} finally {
setIsLoading(false)
interface ReactApiHook<TResponseData> {
(
initialUrl: string,
initialApiResponse: TResponseData,
requestBody?: ApiBodyData
): ApiResponseHookResult<TResponseData>
}

/**
* A helper thunk that creates a useApi* function.
* @param httpMethod The method the useApi* function is using.
* @param sendCsrf True if the CSRF token should be requested and included.
*/
function createApiHook<TResponseData>(
httpMethod: string,
sendCsrf: boolean = true
): ReactApiHook<TResponseData> {
/**
* A hook to use an API response from the local backend API.
* @param initialUrl The url to fetch. This is a RELATIVE path for the local backend API.
* @param initialApiResponse The initial response you want to use until the API responds.
*/
function useApiImp<TResponseData>(
initialUrl: string,
initialApiResponse: TResponseData,
requestBody?: ApiBodyData
): ApiResponseHookResult<TResponseData> {
const [url, setUrl] = useState(initialUrl)
const [response, setResponse] = useState(initialApiResponse)
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const csrfToken = useCsrfToken(sendCsrf)

useEffect(() => {
async function fetchApi(): Promise<void> {
try {
setIsLoading(true)
const requestInit: RequestInit = {
method: httpMethod,
headers: {} as Record<string, string>,
}
if (requestBody) {
requestInit.body = JSON.stringify(requestBody)
}
if (sendCsrf) {
requestInit.headers = {
...requestInit.headers,
"x-csrf-token": await csrfToken,
}
}
const rawData = await fetchJson<TResponseData>(url, requestInit)
setResponse(rawData)
setIsError(false)
} catch (reason) {
// eslint-disable-next-line no-console
console.error(`Error posting to ${url}:`, reason)
setIsError(true)
} finally {
setIsLoading(false)
}
}
}
fetchApi()
}, [url, csrfToken, postBody])
return [{ response, isLoading, isError }, setUrl]
fetchApi()
}, [url, csrfToken, requestBody])
return [{ response, isLoading, isError }, setUrl]
}
return useApiImp
}

/**
* A react hook to return the CSRF token used for authorizing API requests for state-changing requests (PUT, POST, UPDATE, DELETE, PATCH, etc. see https://developer.mozilla.org/en-US/docs/Glossary/safe).
* https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#token-based-mitigation
* @param requestRealToken True if a real CSRF token is needed. False indicates the caller doesn't need the token and the work to fetch it can be avoided.
*/
const useCsrfToken = (): Promise<string> => {
const useCsrfToken = (requestRealToken = true): Promise<string> => {
/**
* What's going on here?
* The goal here is to always return the same promise to useApi* functions and let them wait in a "loading" state while we resolve the accessToken.
Expand Down Expand Up @@ -148,11 +183,17 @@ const useCsrfToken = (): Promise<string> => {
})
useEffect(() => {
async function getToken(): Promise<void> {
const promisedToken = fetchText(`${process.env.PUBLIC_URL}/auth/csrf`)
// now wait on the promise to resolve and when resolved update use it to resolve the original promise that we returned as state:
promisedToken.then(tokenState.resolveToken).catch(tokenState.rejectToken)
if (requestRealToken) {
const promisedToken = fetchText(`${process.env.PUBLIC_URL}/auth/csrf`)
// now wait on the promise to resolve and when resolved update use it to resolve the original promise that we returned as state:
promisedToken
.then(tokenState.resolveToken)
.catch(tokenState.rejectToken)
} else {
tokenState.resolveToken("")
}
}
getToken()
}, [tokenState.rejectToken, tokenState.resolveToken])
}, [tokenState.rejectToken, tokenState.resolveToken, requestRealToken])
return tokenState.promisedToken
}

0 comments on commit df4b20f

Please sign in to comment.