diff --git a/server/core/auth.mjs b/server/core/auth.mjs index dbc01e03..95c6d17a 100644 --- a/server/core/auth.mjs +++ b/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) diff --git a/server/core/servers.mjs b/server/core/servers.mjs index 4fe83207..3c7dc5d1 100644 --- a/server/core/servers.mjs +++ b/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 })) diff --git a/server/graph/resolvers/authentication.mjs b/server/graph/resolvers/authentication.mjs index 358d0b56..b5e70a7c 100644 --- a/server/graph/resolvers/authentication.mjs +++ b/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 */ diff --git a/server/graph/schemas/authentication.graphql b/server/graph/schemas/authentication.graphql index 778a9f39..5da7dbd7 100644 --- a/server/graph/schemas/authentication.graphql +++ b/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! diff --git a/server/models/users.mjs b/server/models/users.mjs index 15040df6..4b4592ee 100644 --- a/server/models/users.mjs +++ b/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 diff --git a/ux/src/App.vue b/ux/src/App.vue index 21f73cb4..65e8ab23 100644 --- a/ux/src/App.vue +++ b/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...`) diff --git a/ux/src/boot/apollo.js b/ux/src/boot/apollo.js index c1ceee2c..44dedfcb 100644 --- a/ux/src/boot/apollo.js +++ b/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 }) diff --git a/ux/src/components/AuthLoginPanel.vue b/ux/src/components/AuthLoginPanel.vue index db1a5075..a2fc866c 100644 --- a/ux/src/components/AuthLoginPanel.vue +++ b/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) { diff --git a/ux/src/pages/AdminGeneral.vue b/ux/src/pages/AdminGeneral.vue index 731b3788..60424535 100644 --- a/ux/src/pages/AdminGeneral.vue +++ b/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! diff --git a/ux/src/pages/AdminLogin.vue b/ux/src/pages/AdminLogin.vue index 18b9043a..6fb7794b 100644 --- a/ux/src/pages/AdminLogin.vue +++ b/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! diff --git a/ux/src/pages/ProfileAvatar.vue b/ux/src/pages/ProfileAvatar.vue index 9b1c1429..1968029f 100644 --- a/ux/src/pages/ProfileAvatar.vue +++ b/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! diff --git a/ux/src/stores/user.js b/ux/src/stores/user.js index 03cc4a7f..0b74135e 100644 --- a/ux/src/stores/user.js +++ b/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 () {