Browse Source

feat: graphql auto refresh token + apollo improvements

NGPixel 2 years ago
parent
commit
eed3675512

+ 2 - 2
server/core/auth.mjs

@@ -109,7 +109,7 @@ export default {
    * @param {Express Next Callback} next
    */
   authenticate (req, res, next) {
-    WIKI.auth.passport.authenticate('jwt', {session: false}, async (err, user, info) => {
+    WIKI.auth.passport.authenticate('jwt', { session: false }, async (err, user, info) => {
       if (err) { return next() }
       let mustRevalidate = false
       const strategyId = user.pvd
@@ -141,7 +141,7 @@ export default {
       }
 
       // Revalidate and renew token
-      if (mustRevalidate) {
+      if (mustRevalidate && !req.path.startsWith('/_graphql')) {
         const jwtPayload = jwt.decode(extractJWT(req))
         try {
           const newToken = await WIKI.db.users.refreshToken(jwtPayload.id, jwtPayload.pvd)

+ 4 - 3
server/core/servers.mjs

@@ -133,17 +133,18 @@ export default {
     const graphqlSchema = await initSchema()
     this.graph = new ApolloServer({
       schema: graphqlSchema,
+      allowBatchedHttpRequests: true,
       csrfPrevention: true,
       cache: 'bounded',
       plugins: [
-        process.env.NODE_ENV === 'development' ? ApolloServerPluginLandingPageLocalDefault({
+        process.env.NODE_ENV === 'production' ? ApolloServerPluginLandingPageProductionDefault({
+          footer: false
+        }) : ApolloServerPluginLandingPageLocalDefault({
           footer: false,
           embed: {
             endpointIsEditable: false,
             runTelemetry: false
           }
-        }) : ApolloServerPluginLandingPageProductionDefault({
-          footer: false
         })
         // ApolloServerPluginDrainHttpServer({ httpServer: this.http })
         // ...(this.https && ApolloServerPluginDrainHttpServer({ httpServer: this.https }))

+ 34 - 0
server/graph/resolvers/authentication.mjs

@@ -1,5 +1,8 @@
 import _ from 'lodash-es'
 import { generateError, generateSuccess } from '../../helpers/graph.mjs'
+import jwt from 'jsonwebtoken'
+import ms from 'ms'
+import { DateTime } from 'luxon'
 
 export default {
   Query: {
@@ -148,6 +151,37 @@ export default {
         return generateError(err)
       }
     },
+    /**
+     * Refresh Token
+     */
+    async refreshToken (obj, args, context) {
+      try {
+        let decoded = {}
+        if (!args.token) {
+          throw new Error('ERR_MISSING_TOKEN')
+        }
+        try {
+          decoded = jwt.verify(args.token, WIKI.config.auth.certs.public, {
+            audience: WIKI.config.auth.audience,
+            issuer: 'urn:wiki.js',
+            algorithms: ['RS256'],
+            ignoreExpiration: true
+          })
+        } catch (err) {
+          throw new Error('ERR_INVALID_TOKEN')
+        }
+        if (DateTime.utc().minus(ms(WIKI.config.auth.tokenRenewal)) > DateTime.fromSeconds(decoded.exp)) {
+          throw new Error('ERR_EXPIRED_TOKEN')
+        }
+        const newToken = await WIKI.db.users.refreshToken(decoded.id)
+        return {
+          jwt: newToken.token,
+          operation: generateSuccess('Token refreshed successfully')
+        }
+      } catch (err) {
+        return generateError(err)
+      }
+    },
     /**
      * Set API state
      */

+ 9 - 0
server/graph/schemas/authentication.graphql

@@ -58,6 +58,10 @@ extend type Mutation {
     name: String!
   ): AuthenticationRegisterResponse
 
+  refreshToken(
+    token: String!
+  ): AuthenticationTokenResponse @rateLimit(limit: 30, duration: 60)
+
   revokeApiKey(
     id: UUID!
   ): DefaultResponse
@@ -128,6 +132,11 @@ type AuthenticationRegisterResponse {
   jwt: String
 }
 
+type AuthenticationTokenResponse {
+  operation: Operation
+  jwt: String
+}
+
 input AuthenticationStrategyInput {
   key: String!
   strategyKey: String!

+ 2 - 3
server/models/users.mjs

@@ -386,7 +386,7 @@ export class User extends Model {
   /**
    * Generate a new token for a user
    */
-  static async refreshToken(user, provider) {
+  static async refreshToken (user) {
     if (isString(user)) {
       user = await WIKI.db.users.query().findById(user).withGraphFetched('groups').modifyGraph('groups', builder => {
         builder.select('groups.id', 'permissions')
@@ -411,8 +411,7 @@ export class User extends Model {
       token: jwt.sign({
         id: user.id,
         email: user.email,
-        groups: user.getGroups(),
-        ...provider && { pvd: provider }
+        groups: user.getGroups()
       }, {
         key: WIKI.config.auth.certs.private,
         passphrase: WIKI.config.auth.secret

+ 5 - 3
ux/src/App.vue

@@ -125,6 +125,11 @@ if (typeof siteConfig !== 'undefined') {
 router.beforeEach(async (to, from) => {
   commonStore.routerLoading = true
 
+  // -> Init Auth Token
+  if (userStore.token && !userStore.authenticated) {
+    userStore.loadToken()
+  }
+
   // -> System Flags
   if (!flagsStore.loaded) {
     flagsStore.load()
@@ -144,9 +149,6 @@ router.beforeEach(async (to, from) => {
     applyLocale(commonStore.desiredLocale)
   }
 
-  // -> User Auth
-  await userStore.refreshAuth()
-
   // -> User Profile
   if (userStore.authenticated && !userStore.profileLoaded) {
     console.info(`Refreshing user ${userStore.id} profile...`)

+ 60 - 7
ux/src/boot/apollo.js

@@ -1,6 +1,7 @@
 import { boot } from 'quasar/wrappers'
-import { ApolloClient, InMemoryCache } from '@apollo/client/core'
+import { ApolloClient, HttpLink, InMemoryCache, from, split } from '@apollo/client/core'
 import { setContext } from '@apollo/client/link/context'
+import { BatchHttpLink } from '@apollo/client/link/batch-http'
 import { createUploadLink } from 'apollo-upload-client'
 
 import { useUserStore } from 'src/stores/user'
@@ -8,27 +9,80 @@ import { useUserStore } from 'src/stores/user'
 export default boot(({ app }) => {
   const userStore = useUserStore()
 
+  const defaultLinkOptions = {
+    uri: '/_graphql',
+    credentials: 'omit'
+  }
+
+  let refreshPromise = null
+  let fetching = false
+
   // Authentication Link
   const authLink = setContext(async (req, { headers }) => {
-    const token = userStore.token
+    if (!userStore.token) {
+      return {
+        headers: {
+          ...headers,
+          Authorization: ''
+        }
+      }
+    }
+
+    // -> Refresh Token
+    if (!userStore.isTokenValid()) {
+      if (!fetching) {
+        refreshPromise = new Promise((resolve, reject) => {
+          (async () => {
+            fetching = true
+            try {
+              await userStore.refreshToken()
+              resolve()
+            } catch (err) {
+              reject(err)
+            }
+            fetching = false
+          })()
+        })
+      } else {
+        // -> Another request is already executing, wait for it to complete
+        await refreshPromise
+      }
+    }
+
     return {
       headers: {
         ...headers,
-        Authorization: token ? `Bearer ${token}` : ''
+        Authorization: userStore.token ? `Bearer ${userStore.token}` : ''
       }
     }
   })
 
   // Upload / HTTP Link
   const uploadLink = createUploadLink({
-    uri () {
-      return '/_graphql'
+    ...defaultLinkOptions,
+    headers: {
+      'Apollo-Require-Preflight': 'true'
     }
   })
 
+  // Directional Link
+  const link = split(
+    op => op.getContext().skipAuth,
+    new HttpLink(defaultLinkOptions),
+    from([
+      authLink,
+      split(
+        op => op.getContext().uploadMode,
+        uploadLink,
+        new BatchHttpLink(defaultLinkOptions)
+      )
+    ])
+  )
+
   // Cache
   const cache = new InMemoryCache()
 
+  // Restore SSR state
   if (typeof window !== 'undefined') {
     const state = window.__APOLLO_STATE__
     if (state) {
@@ -39,8 +93,7 @@ export default boot(({ app }) => {
   // Client
   const client = new ApolloClient({
     cache,
-    link: authLink.concat(uploadLink),
-    credentials: 'omit',
+    link,
     ssrForceFetchDelay: 100
   })
 

+ 1 - 1
ux/src/components/AuthLoginPanel.vue

@@ -502,7 +502,7 @@ async function handleLoginResponse (resp) {
     $q.loading.show({
       message: t('auth.loginSuccess')
     })
-    Cookies.set('jwt', resp.jwt, { expires: 365 })
+    Cookies.set('jwt', resp.jwt, { expires: 365, path: '/', sameSite: 'Lax' })
     setTimeout(() => {
       const loginRedirect = Cookies.get('loginRedirect')
       if (loginRedirect === '/' && resp.redirect) {

+ 6 - 0
ux/src/pages/AdminGeneral.vue

@@ -745,6 +745,9 @@ async function uploadLogo () {
     state.loading++
     try {
       const resp = await APOLLO_CLIENT.mutate({
+        context: {
+          uploadMode: true
+        },
         mutation: gql`
           mutation uploadLogo (
             $id: UUID!
@@ -796,6 +799,9 @@ async function uploadFavicon () {
     state.loading++
     try {
       const resp = await APOLLO_CLIENT.mutate({
+        context: {
+          uploadMode: true
+        },
         mutation: gql`
           mutation uploadFavicon (
             $id: UUID!

+ 3 - 0
ux/src/pages/AdminLogin.vue

@@ -308,6 +308,9 @@ async function uploadBg () {
     state.loading++
     try {
       const resp = await APOLLO_CLIENT.mutate({
+        context: {
+          uploadMode: true
+        },
         mutation: gql`
           mutation uploadLoginBg (
             $id: UUID!

+ 3 - 0
ux/src/pages/ProfileAvatar.vue

@@ -97,6 +97,9 @@ async function uploadImage () {
     state.loading++
     try {
       const resp = await APOLLO_CLIENT.mutate({
+        context: {
+          uploadMode: true
+        },
         mutation: gql`
           mutation uploadUserAvatar (
             $id: UUID!

+ 52 - 20
ux/src/stores/user.js

@@ -24,7 +24,7 @@ export const useUserStore = defineStore('user', {
     iat: 0,
     exp: null,
     authenticated: false,
-    token: '',
+    token: Cookies.get('jwt'),
     profileLoaded: false
   }),
   getters: {
@@ -40,28 +40,60 @@ export const useUserStore = defineStore('user', {
     }
   },
   actions: {
-    async refreshAuth () {
-      if (this.exp && this.exp < DateTime.now()) {
-        return
+    isTokenValid () {
+      return this.exp && this.exp > DateTime.now()
+    },
+    loadToken () {
+      if (!this.token) { return }
+      try {
+        const jwtData = jwtDecode(this.token)
+        this.id = jwtData.id
+        this.email = jwtData.email
+        this.iat = jwtData.iat
+        this.exp = DateTime.fromSeconds(jwtData.exp, { zone: 'utc' })
+        if (this.exp > DateTime.utc()) {
+          this.authenticated = true
+        } else {
+          console.info('Token has expired and will be refreshed on next query.')
+        }
+      } catch (err) {
+        console.warn('Failed to parse JWT. Invalid or malformed.')
       }
-      const jwtCookie = Cookies.get('jwt')
-      if (jwtCookie) {
-        try {
-          const jwtData = jwtDecode(jwtCookie)
-          this.id = jwtData.id
-          this.email = jwtData.email
-          this.iat = jwtData.iat
-          this.exp = DateTime.fromSeconds(jwtData.exp, { zone: 'utc' })
-          this.token = jwtCookie
-          if (this.exp <= DateTime.utc()) {
-            console.info('Token has expired. Attempting renew...')
-            // TODO: Renew token
-          } else {
-            this.authenticated = true
+    },
+    async refreshToken () {
+      try {
+        const respRaw = await APOLLO_CLIENT.mutate({
+          context: {
+            skipAuth: true
+          },
+          mutation: gql`
+            mutation refreshToken (
+              $token: String!
+            ) {
+              refreshToken(token: $token) {
+                operation {
+                  succeeded
+                  message
+                }
+                jwt
+              }
+            }
+          `,
+          variables: {
+            token: this.token
           }
-        } catch (err) {
-          console.debug('Invalid JWT. Silent authentication skipped.')
+        })
+        const resp = respRaw?.data?.refreshToken ?? {}
+        if (!resp.operation?.succeeded) {
+          throw new Error(resp.operation?.message || 'Failed to refresh token.')
         }
+        Cookies.set('jwt', resp.jwt, { expires: 365, path: '/', sameSite: 'Lax' })
+        this.token = resp.jwt
+        this.loadToken()
+        return true
+      } catch (err) {
+        console.warn(err)
+        return false
       }
     },
     async refreshProfile () {