feat: graphql auto refresh token + apollo improvements

pull/6775/head
NGPixel 2 years ago
parent e9fc514c92
commit eed3675512
No known key found for this signature in database
GPG Key ID: B755FB6870B30F63

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

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

@ -1,5 +1,8 @@
import _ from 'lodash-es' import _ from 'lodash-es'
import { generateError, generateSuccess } from '../../helpers/graph.mjs' import { generateError, generateSuccess } from '../../helpers/graph.mjs'
import jwt from 'jsonwebtoken'
import ms from 'ms'
import { DateTime } from 'luxon'
export default { export default {
Query: { Query: {
@ -148,6 +151,37 @@ export default {
return generateError(err) 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 * Set API state
*/ */

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

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

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

@ -1,6 +1,7 @@
import { boot } from 'quasar/wrappers' 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 { setContext } from '@apollo/client/link/context'
import { BatchHttpLink } from '@apollo/client/link/batch-http'
import { createUploadLink } from 'apollo-upload-client' import { createUploadLink } from 'apollo-upload-client'
import { useUserStore } from 'src/stores/user' import { useUserStore } from 'src/stores/user'
@ -8,27 +9,80 @@ import { useUserStore } from 'src/stores/user'
export default boot(({ app }) => { export default boot(({ app }) => {
const userStore = useUserStore() const userStore = useUserStore()
const defaultLinkOptions = {
uri: '/_graphql',
credentials: 'omit'
}
let refreshPromise = null
let fetching = false
// Authentication Link // Authentication Link
const authLink = setContext(async (req, { headers }) => { 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 { return {
headers: { headers: {
...headers, ...headers,
Authorization: token ? `Bearer ${token}` : '' Authorization: userStore.token ? `Bearer ${userStore.token}` : ''
} }
} }
}) })
// Upload / HTTP Link // Upload / HTTP Link
const uploadLink = createUploadLink({ const uploadLink = createUploadLink({
uri () { ...defaultLinkOptions,
return '/_graphql' 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 // Cache
const cache = new InMemoryCache() const cache = new InMemoryCache()
// Restore SSR state
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const state = window.__APOLLO_STATE__ const state = window.__APOLLO_STATE__
if (state) { if (state) {
@ -39,8 +93,7 @@ export default boot(({ app }) => {
// Client // Client
const client = new ApolloClient({ const client = new ApolloClient({
cache, cache,
link: authLink.concat(uploadLink), link,
credentials: 'omit',
ssrForceFetchDelay: 100 ssrForceFetchDelay: 100
}) })

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

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

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

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

@ -24,7 +24,7 @@ export const useUserStore = defineStore('user', {
iat: 0, iat: 0,
exp: null, exp: null,
authenticated: false, authenticated: false,
token: '', token: Cookies.get('jwt'),
profileLoaded: false profileLoaded: false
}), }),
getters: { getters: {
@ -40,28 +40,60 @@ export const useUserStore = defineStore('user', {
} }
}, },
actions: { actions: {
async refreshAuth () { isTokenValid () {
if (this.exp && this.exp < DateTime.now()) { return this.exp && this.exp > DateTime.now()
return },
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) { async refreshToken () {
try { try {
const jwtData = jwtDecode(jwtCookie) const respRaw = await APOLLO_CLIENT.mutate({
this.id = jwtData.id context: {
this.email = jwtData.email skipAuth: true
this.iat = jwtData.iat },
this.exp = DateTime.fromSeconds(jwtData.exp, { zone: 'utc' }) mutation: gql`
this.token = jwtCookie mutation refreshToken (
if (this.exp <= DateTime.utc()) { $token: String!
console.info('Token has expired. Attempting renew...') ) {
// TODO: Renew token refreshToken(token: $token) {
} else { operation {
this.authenticated = true 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 () { async refreshProfile () {

Loading…
Cancel
Save