Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: refresh-token #9286

Merged
merged 17 commits into from
Oct 12, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions web/app/components/swr-initor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SWRConfig } from 'swr'
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import useRefreshToken from '@/hooks/use-refresh-token'

type SwrInitorProps = {
children: ReactNode
Expand All @@ -13,18 +14,31 @@ const SwrInitor = ({
}: SwrInitorProps) => {
const router = useRouter()
const searchParams = useSearchParams()
const consoleToken = searchParams.get('console_token')
const consoleToken = searchParams.get('access_token')
const refreshToken = searchParams.get('refresh_token')
const consoleTokenFromLocalStorage = localStorage?.getItem('console_token')
const refreshTokenFromLocalStorage = localStorage?.getItem('refresh_token')
const [init, setInit] = useState(false)
const { getNewAccessToken } = useRefreshToken()

useEffect(() => {
if (!(consoleToken || consoleTokenFromLocalStorage))
if (!(consoleToken || refreshToken || consoleTokenFromLocalStorage || refreshTokenFromLocalStorage)) {
router.replace('/signin')
return
}
if (consoleTokenFromLocalStorage && refreshTokenFromLocalStorage)
getNewAccessToken(consoleTokenFromLocalStorage, refreshTokenFromLocalStorage)

if (consoleToken) {
localStorage?.setItem('console_token', consoleToken!)
router.replace('/apps', { forceOptimisticNavigation: false } as any)
if (consoleToken && refreshToken) {
localStorage.setItem('console_token', consoleToken)
localStorage.setItem('refresh_token', refreshToken)
getNewAccessToken(consoleToken, refreshToken).then(() => {
router.replace('/apps', { forceOptimisticNavigation: false } as any)
}).catch(() => {
router.replace('/signin')
})
}

setInit(true)
}, [])

Expand Down
6 changes: 5 additions & 1 deletion web/app/signin/normalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { IS_CE_EDITION, SUPPORT_MAIL_LOGIN, apiPrefix, emailRegex } from '@/conf
import Button from '@/app/components/base/button'
import { login, oauth } from '@/service/common'
import { getPurifyHref } from '@/utils'
import useRefreshToken from '@/hooks/use-refresh-token'

type IState = {
formValid: boolean
Expand Down Expand Up @@ -61,6 +62,7 @@ function reducer(state: IState, action: IAction) {

const NormalForm = () => {
const { t } = useTranslation()
const { getNewAccessToken } = useRefreshToken()
const useEmailLogin = IS_CE_EDITION || SUPPORT_MAIL_LOGIN

const router = useRouter()
Expand Down Expand Up @@ -95,7 +97,9 @@ const NormalForm = () => {
},
})
if (res.result === 'success') {
localStorage.setItem('console_token', res.data)
localStorage.setItem('console_token', res.data.access_token)
localStorage.setItem('refresh_token', res.data.refresh_token)
getNewAccessToken(res.data.access_token, res.data.refresh_token)
router.replace('/apps')
}
else {
Expand Down
11 changes: 8 additions & 3 deletions web/app/signin/userSSOForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import cn from '@/utils/classnames'
import Toast from '@/app/components/base/toast'
import { getUserOAuth2SSOUrl, getUserOIDCSSOUrl, getUserSAMLSSOUrl } from '@/service/sso'
import Button from '@/app/components/base/button'
import useRefreshToken from '@/hooks/use-refresh-token'

type UserSSOFormProps = {
protocol: string
Expand All @@ -15,8 +16,10 @@ type UserSSOFormProps = {
const UserSSOForm: FC<UserSSOFormProps> = ({
protocol,
}) => {
const { getNewAccessToken } = useRefreshToken()
const searchParams = useSearchParams()
const consoleToken = searchParams.get('console_token')
const consoleToken = searchParams.get('access_token')
const refreshToken = searchParams.get('refresh_token')
const message = searchParams.get('message')

const router = useRouter()
Expand All @@ -25,8 +28,10 @@ const UserSSOForm: FC<UserSSOFormProps> = ({
const [isLoading, setIsLoading] = useState(false)

useEffect(() => {
if (consoleToken) {
if (refreshToken && consoleToken) {
localStorage.setItem('console_token', consoleToken)
localStorage.setItem('refresh_token', refreshToken)
getNewAccessToken(consoleToken, refreshToken)
router.replace('/apps')
}

Expand All @@ -36,7 +41,7 @@ const UserSSOForm: FC<UserSSOFormProps> = ({
message,
})
}
}, [])
}, [consoleToken, refreshToken, message, router])

const handleSSOLogin = () => {
setIsLoading(true)
Expand Down
92 changes: 92 additions & 0 deletions web/hooks/use-refresh-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
'use client'
import { useCallback, useEffect, useRef } from 'react'
import { jwtDecode } from 'jwt-decode'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import { useRouter } from 'next/navigation'
import type { CommonResponse } from '@/models/common'
import { fetchNewToken } from '@/service/common'
import { fetchWithRetry } from '@/utils'

dayjs.extend(utc)

const useRefreshToken = () => {
const router = useRouter()
const timer = useRef<NodeJS.Timeout>()
const advanceTime = useRef<number>(5 * 60 * 1000)
const interval = useRef<number>(55 * 60 * 1000)

const getExpireTime = useCallback((token: string) => {
if (!token)
return 0
const decoded = jwtDecode(token)
return (decoded.exp || 0) * 1000
}, [])

const getCurrentTimeStamp = useCallback(() => {
iamjoel marked this conversation as resolved.
Show resolved Hide resolved
return dayjs.utc().valueOf()
}, [])

const handleError = useCallback(() => {
localStorage?.removeItem('is_refreshing')
localStorage?.removeItem('console_token')
localStorage?.removeItem('refresh_token')
localStorage?.removeItem('last_refresh_time')
router.replace('/signin')
}, [])

const getNewAccessToken = useCallback(async (currentAccessToken: string, currentRefreshToken: string) => {
if (localStorage?.getItem('is_refreshing') === '1')
return null
const currentTokenExpireTime = getExpireTime(currentAccessToken)
let lastRefreshTime = parseInt(localStorage?.getItem('last_refresh_time') || '0')
lastRefreshTime = isNaN(lastRefreshTime) ? 0 : lastRefreshTime
if (getCurrentTimeStamp() + advanceTime.current > currentTokenExpireTime
iamjoel marked this conversation as resolved.
Show resolved Hide resolved
&& lastRefreshTime + interval.current < getCurrentTimeStamp()) {
iamjoel marked this conversation as resolved.
Show resolved Hide resolved
localStorage?.setItem('is_refreshing', '1')
const [e, res] = await fetchWithRetry(fetchNewToken({
body: { refresh_token: currentRefreshToken },
}) as Promise<CommonResponse & { data: { access_token: string; refresh_token: string } }>)
if (e) {
handleError()
return e
}
const { access_token, refresh_token } = res.data
localStorage?.setItem('is_refreshing', '0')
localStorage?.setItem('last_refresh_time', getCurrentTimeStamp().toString())
localStorage?.setItem('console_token', access_token)
localStorage?.setItem('refresh_token', refresh_token)
const newTokenExpireTime = getExpireTime(access_token)
timer.current = setTimeout(() => {
const consoleTokenFromLocalStorage = localStorage?.getItem('console_token')
const refreshTokenFromLocalStorage = localStorage?.getItem('refresh_token')
if (consoleTokenFromLocalStorage && refreshTokenFromLocalStorage)
getNewAccessToken(consoleTokenFromLocalStorage, refreshTokenFromLocalStorage)
}, newTokenExpireTime - advanceTime.current - getCurrentTimeStamp())
}
else {
const newTokenExpireTime = getExpireTime(currentAccessToken)
timer.current = setTimeout(() => {
const consoleTokenFromLocalStorage = localStorage?.getItem('console_token')
const refreshTokenFromLocalStorage = localStorage?.getItem('refresh_token')
if (consoleTokenFromLocalStorage && refreshTokenFromLocalStorage)
getNewAccessToken(consoleTokenFromLocalStorage, refreshTokenFromLocalStorage)
}, newTokenExpireTime - advanceTime.current - getCurrentTimeStamp())
}
return null
}, [getExpireTime, getCurrentTimeStamp, handleError])

useEffect(() => {
return () => {
clearTimeout(timer.current)
localStorage?.removeItem('is_refreshing')
localStorage?.removeItem('last_refresh_time')
}
}, [])

return {
getNewAccessToken,
}
}

export default useRefreshToken
1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"immer": "^9.0.19",
"js-audio-recorder": "^1.0.7",
"js-cookie": "^3.0.1",
"jwt-decode": "^4.0.0",
"katex": "^0.16.10",
"lamejs": "^1.2.1",
"lexical": "^0.16.0",
Expand Down
17 changes: 15 additions & 2 deletions web/service/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,21 @@ import type {
import type { RETRIEVE_METHOD } from '@/types/app'
import type { SystemFeatures } from '@/types/feature'

export const login: Fetcher<CommonResponse & { data: string }, { url: string; body: Record<string, any> }> = ({ url, body }) => {
return post(url, { body }) as Promise<CommonResponse & { data: string }>
type LoginSuccess = {
result: 'success'
data: { access_token: string;refresh_token: string }
}
type LoginFail = {
result: 'fail'
data: string
}
type LoginResponse = LoginSuccess | LoginFail
export const login: Fetcher<LoginResponse, { url: string; body: Record<string, any> }> = ({ url, body }) => {
return post(url, { body }) as Promise<LoginResponse>
}

export const fetchNewToken: Fetcher<CommonResponse & { data: { access_token: string; refresh_token: string } }, { body: Record<string, any> }> = ({ body }) => {
return post('/refresh-token', { body }) as Promise<CommonResponse & { data: { access_token: string; refresh_token: string } }>
}

export const setup: Fetcher<CommonResponse, { body: Record<string, any> }> = ({ body }) => {
Expand Down
18 changes: 18 additions & 0 deletions web/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,21 @@ export const getPurifyHref = (href: string) => {

return escape(href)
}

export async function fetchWithRetry<T = any>(fn: Promise<T>, retries = 3): Promise<[Error] | [null, T]> {
const [error, res] = await asyncRunSafe(fn)
if (error) {
if (retries > 0) {
const res = await fetchWithRetry(fn, retries - 1)
return res
}
else {
if (error instanceof Error)
return [error]
return [new Error('unknown error')]
}
}
else {
return [null, res]
}
}
Loading