Skip to content

Commit

Permalink
Merge pull request #656 from microsoft/655-offline-authentication
Browse files Browse the repository at this point in the history
655 offline authentication
  • Loading branch information
mdeitner authored Jul 14, 2022
2 parents 858db9c + d030557 commit 4c4295a
Show file tree
Hide file tree
Showing 18 changed files with 229 additions and 98 deletions.
6 changes: 3 additions & 3 deletions packages/webapp/src/api/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,10 @@ const cache: InMemoryCache = new InMemoryCache({
}
})

export function getCache() {
if (isDurableCacheInitialized) {
export function getCache(reloadCache = false) {
if (isDurableCacheInitialized && !reloadCache) {
logger('durable cache is enabled')
} else if (!isDurableCacheInitialized && isDurableCacheEnabled) {
} else if (isDurableCacheEnabled) {
persistCache({ cache, storage: new LocalForageWrapperEncrypted(localForage) })
.then(() => {
isDurableCacheInitialized = true
Expand Down
2 changes: 1 addition & 1 deletion packages/webapp/src/api/createErrorLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,6 @@ export function createErrorLink(history: History) {
})
}

const UNAUTHENTICATED = 'UNAUTHENTICATED'
export const UNAUTHENTICATED = 'UNAUTHENTICATED'
const TOKEN_EXPIRED = 'TOKEN_EXPIRED'
const TOKEN_EXPIRED_ERROR = 'TokenExpiredError'
6 changes: 4 additions & 2 deletions packages/webapp/src/api/getHeaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
* Licensed under the MIT license. See LICENSE file in the project.
*/

import { retrieveAccessToken, retrieveLocale } from '~utils/localStorage'
import { retrieveLocale } from '~utils/localStorage'
import { getAccessToken, getCurrentUserId } from '~utils/localCrypto'

export interface RequestHeaders {
authorization?: string
Expand All @@ -21,7 +22,8 @@ export function getHeaders(): RequestHeaders {
if (typeof window === 'undefined') return {}

// Get values from recoil local store
const accessToken = retrieveAccessToken()
const currentUserId = getCurrentUserId()
const accessToken = getAccessToken(currentUserId)
const accept_language = retrieveLocale()

// Return node friendly headers
Expand Down
9 changes: 6 additions & 3 deletions packages/webapp/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { History } from 'history'
import { createHttpLink } from './createHttpLink'
import { createWebSocketLink } from './createWebSocketLink'
import { createErrorLink } from './createErrorLink'
import type QueueLink from '../utils/queueLink'
import type QueueLink from '~utils/queueLink'

/**
* Configures and creates the Apollo Client.
Expand All @@ -24,12 +24,13 @@ const isNodeServer = typeof window === 'undefined'

export function createApolloClient(
history: History,
queueLink: QueueLink
queueLink: QueueLink,
reloadCache: boolean
): ApolloClient<NormalizedCacheObject> {
return new ApolloClient({
ssrMode: isNodeServer,
link: createRootLink(history, queueLink),
cache: getCache()
cache: getCache(reloadCache)
})
}

Expand All @@ -54,3 +55,5 @@ function isSubscriptionOperation({ query }: Operation) {
const definition = getMainDefinition(query)
return definition.kind === 'OperationDefinition' && definition.operation === 'subscription'
}

export { UNAUTHENTICATED } from './createErrorLink'
24 changes: 17 additions & 7 deletions packages/webapp/src/api/local-forage-encrypted-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { LocalForageWrapper } from 'apollo3-cache-persist'
import * as CryptoJS from 'crypto-js'
import { currentUserStore } from '~utils/current-user-store'
import { checkSalt, setPwdHash, setCurrentUser, getCurrentUser } from '~utils/localCrypto'
import { checkSalt, setPwdHash, setCurrentUserId, getCurrentUserId } from '~utils/localCrypto'

export class LocalForageWrapperEncrypted extends LocalForageWrapper {
constructor(
Expand All @@ -14,13 +14,16 @@ export class LocalForageWrapperEncrypted extends LocalForageWrapper {
passwd = 'notusedbyusers'
) {
super(storage)
checkSalt(user)
setPwdHash(user, passwd)
setCurrentUser(user)
const currentUser = getCurrentUserId()
if (!currentUser) {
checkSalt(user)
setPwdHash(user, passwd)
setCurrentUserId(user)
}
}

getItem(key: string): Promise<string | null> {
const currentUid = getCurrentUser()
const currentUid = getCurrentUserId()
return super.getItem(currentUid.concat('-', key)).then((item) => {
if (item != null && item.length > 0) {
return this.decrypt(item, currentUid)
Expand All @@ -30,22 +33,29 @@ export class LocalForageWrapperEncrypted extends LocalForageWrapper {
}

removeItem(key: string): Promise<void> {
const currentUid = getCurrentUser()
const currentUid = getCurrentUserId()
return super.removeItem(currentUid.concat('-', key))
}

setItem(key: string, value: string | object | null): Promise<void> {
const currentUid = getCurrentUser()
const currentUid = getCurrentUserId()
const secData = this.encrypt(value, currentUid)
return super.setItem(currentUid.concat('-', key), secData)
}

private encrypt(data, currentUid): string {
if (!currentUserStore.state.sessionPassword) {
return
}
const edata = CryptoJS.AES.encrypt(data, currentUserStore.state.sessionPassword).toString()
return edata
}

private decrypt(cdata, currentUid): string {
if (!currentUserStore.state.sessionPassword) {
return null
}

const dataBytes = CryptoJS.AES.decrypt(cdata, currentUserStore.state.sessionPassword)
return dataBytes.toString(CryptoJS.enc.Utf8)
}
Expand Down
21 changes: 12 additions & 9 deletions packages/webapp/src/components/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { FC } from 'react'
import { memo } from 'react'
import { BrowserRouter } from 'react-router-dom'
import { config } from '~utils/config'
import { RecoilRoot } from 'recoil'

export const App: FC = memo(function App() {
// Set the environment name as an attribute
Expand All @@ -24,15 +25,17 @@ export const App: FC = memo(function App() {
return (
<BrowserRouter basename='/'>
<Measured>
<Stateful>
<Progressive>
<Localized>
<Frameworked>
<Routes />
</Frameworked>
</Localized>
</Progressive>
</Stateful>
<RecoilRoot>
<Stateful>
<Progressive>
<Localized>
<Frameworked>
<Routes />
</Frameworked>
</Localized>
</Progressive>
</Stateful>
</RecoilRoot>
</Measured>
</BrowserRouter>
)
Expand Down
12 changes: 12 additions & 0 deletions packages/webapp/src/components/app/Routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import { AuthorizedRoutes } from './AuthorizedRoutes'
import { ApplicationRoute } from '~types/ApplicationRoute'
import { useCurrentUser } from '~hooks/api/useCurrentUser'
import { LoadingPlaceholder } from '~ui/LoadingPlaceholder'
import { config } from '~utils/config'
import { currentUserStore } from '~utils/current-user-store'

const logger = createLogger('Routes')

const Login = lazy(() => /* webpackChunkName: "LoginPage" */ import('~pages/login'))
Expand All @@ -20,6 +23,15 @@ const PasswordReset = lazy(
export const Routes: FC = memo(function Routes() {
const location = useLocation()
const { currentUser } = useCurrentUser()

// When saving encrypted data (durableCache), a session key is required (stored during login)
if (Boolean(config.features.durableCache.enabled)) {
const sessionPassword = currentUserStore.state.sessionPassword
if (!sessionPassword) {
location.pathname = '/login'
}
}

useEffect(() => {
logger('routes rendering', location.pathname)
}, [location.pathname])
Expand Down
22 changes: 14 additions & 8 deletions packages/webapp/src/components/app/Stateful.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,20 @@ import type { History } from 'history'
import type { FC } from 'react'
import { useEffect, memo } from 'react'
import { useHistory } from 'react-router-dom'
import { RecoilRoot } from 'recoil'
import { useRecoilState } from 'recoil'
import { createApolloClient } from '~api'
import QueueLink from '../../utils/queueLink'
import QueueLink from '~utils/queueLink'
import { useOffline } from '~hooks/useOffline'
import { sessionPasswordState } from '~store'

// Create an Apollo Link to queue request while offline
const queueLink = new QueueLink()

export const Stateful: FC = memo(function Stateful({ children }) {
const history: History = useHistory() as any
const apiClient = createApolloClient(history, queueLink)
let apiClient = createApolloClient(history, queueLink, false)
const isOffline = useOffline()
const [sessionPassword] = useRecoilState(sessionPasswordState)

useEffect(() => {
if (isOffline) {
Expand All @@ -28,9 +30,13 @@ export const Stateful: FC = memo(function Stateful({ children }) {
}
}, [isOffline])

return (
<ApolloProvider client={apiClient}>
<RecoilRoot>{children}</RecoilRoot>
</ApolloProvider>
)
useEffect(() => {
if (sessionPassword) {
// TODO: fix lint error generated by line below
// eslint-disable-next-line
apiClient = createApolloClient(history, queueLink, true)
}
}, [sessionPassword])

return <ApolloProvider client={apiClient}>{children}</ApolloProvider>
})
69 changes: 50 additions & 19 deletions packages/webapp/src/components/forms/LoginForm/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,31 @@ import { FormikField } from '~ui/FormikField'
import { Formik, Form } from 'formik'
import cx from 'classnames'
import { useAuthUser } from '~hooks/api/useAuth'
import { useRecoilState } from 'recoil'
import { useCallback, useState } from 'react'
import { useHistory } from 'react-router-dom'
import { currentUserState, sessionPasswordState } from '~store'
import type { User } from '@cbosuite/schema/dist/client-types'
import { Namespace, useTranslation } from '~hooks/useTranslation'
import { FormSectionTitle } from '~components/ui/FormSectionTitle'
import { wrap } from '~utils/appinsights'
import { Checkbox } from '@fluentui/react'
import { noop } from '~utils/noop'
import { useNavCallback } from '~hooks/useNavCallback'
import { ApplicationRoute } from '~types/ApplicationRoute'
import {
clearUser,
testPassword,
setCurrentUser,
checkSalt,
APOLLO_KEY,
setPwdHash
} from '~utils/localCrypto'
import { testPassword, APOLLO_KEY, getUser } from '~utils/localCrypto'
import { createLogger } from '~utils/createLogger'
import localforage from 'localforage'
import { config } from '~utils/config'
import { useStore } from 'react-stores'
import { currentUserStore } from '~utils/current-user-store'
import * as CryptoJS from 'crypto-js'
import { StatusType } from '~hooks/api'
import { useOffline } from '~hooks/useOffline'
import { navigate } from '~utils/navigate'
import { OfflineEntityCreationNotice } from '~components/ui/OfflineEntityCreationNotice'
import { UNAUTHENTICATED } from '~api'

const logger = createLogger('authenticate')

interface LoginFormProps {
Expand All @@ -48,6 +51,11 @@ export const LoginForm: StandardFC<LoginFormProps> = wrap(function LoginForm({
const { t } = useTranslation(Namespace.Login)
const { login } = useAuthUser()
const [acceptedAgreement, setAcceptedAgreement] = useState(false)
const isOffline = useOffline()
const [, setCurrentUser] = useRecoilState<User | null>(currentUserState)
const [, setSessionPassword] = useRecoilState(sessionPasswordState)

const history = useHistory()

const handleLoginClick = useCallback(
async (values) => {
Expand All @@ -57,31 +65,44 @@ export const LoginForm: StandardFC<LoginFormProps> = wrap(function LoginForm({
const onlineAuthStatus = resp.status === 'SUCCESS'
const offlineAuthStatus = testPassword(values.username, values.password)
localUserStore.username = values.username
setCurrentUser(values.username)
if (onlineAuthStatus && offlineAuthStatus) {
// Store session password in react store so we can use it in LocalForageWrapperEncrypted class (recoil hook cannot be used in class)
// Store the session password in recoil so we can trigger loading and decrypting the cache from persistent storage (see Stateful.tsx)
localUserStore.sessionPassword = CryptoJS.SHA512(values.password).toString(
CryptoJS.enc.Hex
)
setSessionPassword(localUserStore.sessionPassword)

logger('Online and offline authentication successful!')
} else if (onlineAuthStatus && !offlineAuthStatus) {
clearUser(values.username)
// Store session password in react store so we can use it in LocalForageWrapperEncrypted class (recoil hook cannot be used in class)
// Store the session password in recoil so we can trigger loading and decrypting the cache from persistent storage (see Stateful.tsx)
localUserStore.sessionPassword = CryptoJS.SHA512(values.password).toString(
CryptoJS.enc.Hex
)
checkSalt(values.username) // will create new salt if none found
setPwdHash(values.username, values.password)
setSessionPassword(localUserStore.sessionPassword)

localforage
.removeItem(values.username.concat(APOLLO_KEY))
.then(() => logger(`Apollo persistent storage has been cleared.`))
logger('Password seems to have changed, clearing stored encrypted data.')
} else if (!onlineAuthStatus && offlineAuthStatus) {
localUserStore.sessionPassword = CryptoJS.SHA512(values.username).toString(
} else if (!onlineAuthStatus && offlineAuthStatus && isOffline) {
// Store session password in react store so we can use it in LocalForageWrapperEncrypted class (recoil hook cannot be used in class)
// Store the session password in recoil so we can trigger loading and decrypting the cache from persistent storage (see Stateful.tsx)
localUserStore.sessionPassword = CryptoJS.SHA512(values.password).toString(
CryptoJS.enc.Hex
)
logger(
'Handle offline auth success: WIP/TBD, need to check offline status and data availability'
)
} else if (!onlineAuthStatus && !offlineAuthStatus) {
setSessionPassword(localUserStore.sessionPassword)

const userJsonString = getUser(values.username)
const user = JSON.parse(userJsonString)
setCurrentUser(user)
resp.status = StatusType.Success

logger('Offline authentication successful')
} else if (!offlineAuthStatus && isOffline) {
navigate(history, ApplicationRoute.Login, { error: UNAUTHENTICATED })

logger('Handle offline login failure: WIP/TBD, limited retry?')
} else {
logger('Durable cache authentication problem.')
Expand All @@ -90,12 +111,22 @@ export const LoginForm: StandardFC<LoginFormProps> = wrap(function LoginForm({

onLoginClick(resp.status)
},
[login, onLoginClick, isDurableCacheEnabled, localUserStore]
[
login,
onLoginClick,
isDurableCacheEnabled,
localUserStore,
isOffline,
setCurrentUser,
history,
setSessionPassword
]
)
const handlePasswordResetClick = useNavCallback(ApplicationRoute.PasswordReset)

return (
<>
<OfflineEntityCreationNotice isEntityCreation={false} />
<Row className='mb-5'>
<h2>{t('login.title')}</h2>
</Row>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,20 @@
* Copyright (c) Microsoft. All rights reserved.
* Licensed under the MIT license. See LICENSE file in the project.
*/
import type { FC } from 'react'
import { wrap } from '~utils/appinsights'
import { useOffline } from '~hooks/useOffline'
import { useTranslation } from '~hooks/useTranslation'
import styles from './index.module.scss'
import cx from 'classnames'

export const OfflineEntityCreationNotice = wrap(function OfflineEntityCreationNotice() {
const isOffline = useOffline()
const { c } = useTranslation()
export const OfflineEntityCreationNotice: FC<{ isEntityCreation?: boolean }> = wrap(
function OfflineEntityCreationNotice({ isEntityCreation = true }) {
const isOffline = useOffline()
const { c } = useTranslation()

return (
<>
{isOffline && <div className={cx(styles.notice)}> {c('offline.entityCreationNotice')} </div>}
</>
)
})
const notice = isEntityCreation ? c('offline.entityCreationNotice') : c('offline.generalNotice')

return <>{isOffline && <div className={cx(styles.notice)}> {notice} </div>}</>
}
)
Loading

0 comments on commit 4c4295a

Please sign in to comment.