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
*/
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)

@ -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 }))

@ -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
*/

@ -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!

@ -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

@ -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...`)

@ -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
})

@ -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) {

@ -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!

@ -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!

@ -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!

@ -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 () {

Loading…
Cancel
Save