Javascript-форум (https://javascript.ru/forum/)
-   Библиотеки/Тулкиты/Фреймворки (https://javascript.ru/forum/library-toolkit-framework/)
-   -   Vue.js: refresh token без Axios и т.п. (https://javascript.ru/forum/library-toolkit-framework/84233-vue-js-refresh-token-bez-axios-i-t-p.html)

shurikkan 14.07.2022 21:42

Vue.js: refresh token без Axios и т.п.
 
Привет всем.

У меня есть небольшое приложение на Vue.js (2.6).
В приложении можно авторизоваться. Токен (JWT) живёт 1 день.
Недавно решил посмотреть, как работают системы с рефреш токенами (надоело всё время авторизовываться заново). Увы, не смог найти ничего путного, кроме как решения с Axios.

Так встали звёзды, что я не люблю Axios, да и вообще какие-то сторонние библиотеки для запросов. Да и в целом не люблю сторонние пакеты - Vue и так за собой 100500 зависимостей тянет...

Приложения работает на нативном Fetch API.

На сколько я понимаю принцип работы рефреш токена, если в ответ на запрос нам прилетает "токен просрочен", то мы сохраняем параметры последнего запроса и делаем запрос на получение свежего токена. Если приходит новый токен, достаём последний сохранённый запрос и повторяем его с новым токеном.

Вот пример рядового запроса из модуля в сторе:
async readUsers({rootState, state, getters, dispatch}, payload) {

    const token = rootState.token.access

    if(token) {

        state.usersProcess = true

        let params = ''

        if(payload) {

            for( let param in payload) {

                params.length ?
                    params += '&' + param + '=' + payload[param] :
                    params = '?' + param + '=' + payload[param]
            }
        }

        const request = await dispatch('fetchRequest', {
            address: `${rootState.config.httpServer}/users${params}`, 
            method: 'GET', 
            token: true
        })

        if(!request.error) {

            state.users = request
            state.usersMsg = { success: true }

        } else {

            state.usersMsg = { success: false }
            state.usersMsg.text = getters.getTranslate[request.error] || request.error
        }

        state.usersProcess = false

    } else {

        state.usersMsg = { success: false, text: getters.getTranslate.EUnauthRequest }
    }
}


Для запросов написал такой "хелпер":
async fetchRequest({rootState, dispatch}, payload) {

    let parameters = {

        method: payload.method,
        cache: 'no-cache',
        referrerPolicy: 'origin-when-cross-origin',
        headers: new Headers()
    }

    if(payload.token) {

        parameters.headers.append('Authorization', 'Bearer ' + rootState.token.access)
    } 

    if(payload.data) {

        f(payload.type) {

            payload.type == 'json' ?
                parameters.body = JSON.stringify(payload.data) :
                parameters.body = payload.data

        } else {

            parameters.body = JSON.stringify(payload.data)
        }
    }

    try {

        const request = await fetch(payload.address, parameters)

        const response = await request.text()

        const result = JSON.parse( response )

        if(request.ok) {

            return result

        } else if(result.details) {

            if(result.details == 'TokenExpired') {

               dispatch('refreshToken').then(() => {

                    if(rootState.token.access) {

                        return dispatch('fetchRequest', payload)
                    }
                })

            } else if(result.details == 'TokenError') {

                localStorage.clear()
                document.location.reload()
            }

            return { error: result.details }

        } else if(result.message) {

            return { error: result.message }

        } else {

            return { error: 'EUnknown' }
        }

    } catch {

        return { error: 'EConnection' }
    }
}


Экшен для получения свежего токена:
async refreshToken({rootState, state, dispatch}) {

    const request = await dispatch('fetchRequest', {
        address: `${rootState.config.httpServer}/refresh_tokens`, 
        method: 'POST', 
        data: `"${rootState.token.refresh}"`,
        type: 'string'
    })

    if(request.tokens) {

        state.access = request.tokens.access
        state.refresh = request.tokens.refresh

        localStorage.setItem('TOKEN_ACCESS', state.access)
        localStorage.setItem('TOKEN_REFRESH', state.refresh)

    } else {

        state.access = null
        state.refresh = null

        localStorage.clear()
    }
}


И тут я столкнулся с проблемой: каждый компонент с эарана отправляет асинхронные запросы, несколько компонентов могут получить ответ "токен просрочен" практически одновременно. Затем каждый из них отправит запрос на обновление токена, послав параметром старый. Первому компоненту вернётся корректный ответ (новый токен). А вот получив запрос с от остальных компонентов сервер уже вернёт ошибку "неверный токен", т.к. такого у него уже нет (он был заменён другом ранее).

Вероятно, с самого начала ошибка проектирования, но как сделать по другому никак не могу сообразить. На сколько я понимаю, нужен какой-нибудь промис, по которому будет понятно находится ли система в стадии восстановления токена и, по окончании которого, нужно будет продолжить запросы уже с новым токеном. Но в реализации что-то совсем запутался.

Пытался объяснить максимально подробно, надеюсь, что понятно о чём я говорю :)

Подсобите советом!

voraa 15.07.2022 09:13

Только как идея (без подробной привязке к Вашему коду)

Допустим есть функция обновления токена
async refreshToken (....) {
// awit Запрос
return token // Она вернет Promise разрешенный с значением токена
}

Когда нам надо обновить токен, делаем

let getToken = refreshToken (....)

getToken - это промис, который разрешается с новым значение токена

Теперь, когда нам нужно как то использовать это значение, мы просто делаем
await getToken
Если промис еще не разрешен, то будет ожидание его разрешения, если функция refreshToken уже завершила получения токена, то сразу получаем это значение.
Так можно было бы написать

parameters.headers.append('Authorization', 'Bearer ' + await getToken)

ksa 15.07.2022 09:25

Цитата:

Сообщение от shurikkan
Вероятно, с самого начала ошибка проектирования, но как сделать по другому никак не могу сообразить.

Авторизованные запросы придется делать в цепочку промисов, один за другим.
В противном случае они будут "воровать" токены друг у друга.

ksa 15.07.2022 09:53

voraa, у нас не получилось "узнавать" о состоянии токена (обновляется или нет)... :(

Используем Axios и его интерцепторы. React + MobX... Поскольку там куча всего асинхронного не получилось "точно" узнавать в каком состоянии сейчас находится токены, а помирал наш акцесс через каждые 10мин.

Т.ч. в итоге все авторизованные запросы переделали в цепочки и тем закрыли проблему "воровства" токенов запросами друг у друга.

voraa 15.07.2022 10:05

Цитата:

Сообщение от ksa
В противном случае они будут "воровать" токены друг у друга.

Я так понял, что здесь это и надо. Что бы после обновления токена все запросы делались именно с ним. Пока он не помрет. Как помер - обновили и дальше опять все запросы с ним.

voraa 15.07.2022 10:10

Цитата:

Сообщение от ksa
у нас не получилось "узнавать" о состоянии токена (обновляется или нет)..

Мы просто не сможем сделать запрос с токеном, когда он обновляется.
Что бы его передать мы должны взять его из промиса. А пока промис на обновления токена не разрешился, мы будем ждать.

ksa 15.07.2022 10:29

voraa, я понял твою идею...

Сейчас побеседовал со своими - утверждают что решили и эту проблему. Но немного иначе...
Если попали на неправильный токен, но его кто-то обновляет - просто ждут окончания обновления.

Но твой вариант мне нравится больше. :)

voraa 15.07.2022 10:40

Тут есть одна трабла.
Допустим токен устарел
- Посылаем запрос1
- посылаем запрос2
- Приходит ответ на запрос1, что токен сдох
запускаем обновление
- Приходит ответ на запрос 2, что токен сдох
(и вот тут мы не должны запускать обновление! мы просто должны ждать, пока не придет новый токен) и потом повторять запрос 2 уже с ним)

Т.е в процедуре обновления выставляем флаг inRefresh = true. И снимаем его когда пришел токен.

А когда на запрос получаем ответ, что токен устарел, то проверяем этот флаг. Если установлен, значит его уже обновляют. И мы не запускаем обновление, а просто повторяем запрос - там уже есть ожидание обновления токена.

ksa 15.07.2022 10:56

Так и есть. :yes:

shurikkan 15.07.2022 11:08

Цитата:

Сообщение от voraa (Сообщение 546682)
Тут есть одна трабла.
...
- Посылаем запрос1
- посылаем запрос2
- Приходит ответ на запрос1, что токен сдох
запускаем обновление
- Приходит ответ на запрос 2, что токен сдох
...

Если бы всё было так, то беды с вылетом на экран авторизации не было бы :) Это уже было бы не так плохо. Просто была бы куча запросов в момент истечения срока жизни токена (от каждого компонента на экране)... Но сколько API я не видел - во всех система немного другая:
- Посылаем запрос1
- посылаем запрос2
- Приходит ответ на запрос1, что токен сдох
запускаем обновление
- Приходит ответ на запрос 2, что токен НЕВАЛИДНЫЙ

После первого запроса старая связка access-refresh уже обновлена. Сервер не хранит старые связки (чтобы не засорять память), поэтому получил весуществующий рефреш-токен в запросе просто вернёт ошибку. Ну и фронт, соответственно, увидев ошибку, а не устарение, выбросит юзера из приложения.

voraa 15.07.2022 11:35

Ну тогда только исключать параллельные запросы.
Соединять их в цепочку помисов (как писал ksa)

voraa 15.07.2022 11:46

Цитата:

Сообщение от shurikkan
Приходит ответ на запрос 2, что токен НЕВАЛИДНЫЙ

А нельзя после этой ошибки повторить запрос с новым (обновленным) токеном?

Vlasenko Fedor 15.07.2022 12:29

здесь есть несколько интересных моментов
1. в теле токена есть дата когда он протухнет. незачем дергать сервак с заранее протухшим токеном. (Лень фронтэндщика :lol: )
2. вопрос это по поводу токенов. Типичная работа с токенами jwt подразумевает возможность иметь множество валидных токенов(Токены не хранятся в базе!!! ). Разные клиенты, браузеры ...
В данном случае шансы зацикливания минимальны.
3. Всегда можно реализовать очередь, очередь с приоритетами или выбрать другое пдходящее решение
Что до ТС, самый лучший код - отсутствие кода.
Когда я смотрю на ваш код, думаю, зачем вы его пишите. Может вам платят за кол-во

shurikkan 15.07.2022 13:46

Цитата:

Сообщение от Vlasenko Fedor (Сообщение 546689)
1. в теле токена есть дата когда он протухнет. незачем дергать сервак с заранее протухшим токеном. (Лень фронтэндщика :lol: )

Спасибо за замечание. Пока не планируем разбирать токены на клиенте.

Цитата:

Сообщение от Vlasenko Fedor (Сообщение 546689)
2. вопрос это по поводу токенов. Типичная работа с токенами jwt подразумевает возможность иметь множество валидных токенов(Токены не хранятся в базе!!! ). Разные клиенты, браузеры ...
В данном случае шансы зацикливания минимальны.

Не понял. После каждой авторизации (разные устройства, браузеры) для пользователя создаётся отдельный токен, да. Или что ты имеешь ввиду под "множество валидных токенов"? Как "множество валидных токенов" связано с зацикливанием?

Цитата:

Сообщение от Vlasenko Fedor (Сообщение 546689)
3. Всегда можно реализовать очередь, очередь с приоритетами или выбрать другое пдходящее решение

Совет из разряда "написать код на компьютере для того, чтобы что-то работало")) Ну ок.

Цитата:

Сообщение от Vlasenko Fedor (Сообщение 546689)
Когда я смотрю на ваш код, думаю, зачем вы его пишите. Может вам платят за кол-во

Наверное, ты не поверишь, но всё же: не все с рождения умеют делать всё правильно :yes: Или форумы и проф.комьюнити не нужны?

shurikkan 15.07.2022 13:49

Цитата:

Сообщение от voraa (Сообщение 546687)
А нельзя после этой ошибки повторить запрос с новым (обновленным) токеном?

Не представляю как это сделать.
Пришло "просрочен" - отправляем рефреш.
Пришло "не верный" - выбрасываем.
А если после каждого "не верный" отправлять рефреш - то по какому индикаторы стоит прекратить его отправлять?))

shurikkan 15.07.2022 13:55

Цитата:

Сообщение от voraa (Сообщение 546686)
Ну тогда только исключать параллельные запросы.
Соединять их в цепочку помисов (как писал ksa)

Была попытка сделать нечто типо такого, но в итоге всё равно изредка выбрасывало. Так и не смог тогда диагностировать что где не успевает обновиться.
Создали в сторе свойство (bool), которое переключалось в true после того, как какой-то компонент приложения посылал рефреш. Остальные компоненты, во время запроса проверяли это свойство и, если оно true - ждали изменение в false, затем получани токен (через rootState, из модуля с токеном, в котором уже обновились ключи) и продолжали запрос.
Покопаюсь в коммитах и постараюсь выложить код из той ветки :write:

Самый простой вариант, как я понимаю - это просто убрать у экшенов async?)) Но вот совсем не хочется загружать компоненты в приложении синхронно...

Vlasenko Fedor 15.07.2022 14:25

Цитата:

Сообщение от shurikkan
Не представляю как это сделать.

.catch(async e => {
                if (e.response.status === 401 && checkExp(this.refreshToken) && await this.refresh()) {
                    return this[method](path, params)
                } else {
                    console.log(e)
                    return location.href = '/login'
                }
            })

и токены лучше проверять
const parse = token => {
  const base64Url = token.split('.')[1];
  const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
  return JSON.parse(decodeURIComponent(atob(base64).split('')
    .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join('')));
}
const check = (token, type)=> {
  try {
    const data = parse(token)
    return (new Date(data[type] * 1000)) > (new Date())
  } catch (_) {
    return false;
  }
}
export default {
  checkExp(token) {
    return  check(token, 'exp')
  },
  checkIat(token) {
    return  check(token, 'iat')
  }
}

voraa 15.07.2022 14:31

Цитата:

Сообщение от shurikkan
Пришло "просрочен" - отправляем рефреш.
Пришло "не верный" - выбрасываем.

Если я правильно понимаю, "Не верный" приходит в ответ на запрос2, когда у запроса1 был "просрочен" и после этого сделали рефреш ( а запрос2 все еще обращается со стары токеном) .
Ну и повторите запрос2 уже с новым токеном, а не выбрасывайте. Дождитесь окончания рефреша (через промис) и повторите.

Цитата:

Сообщение от shurikkan
А если после каждого "не верный" отправлять рефреш - то по какому индикаторы стоит прекратить его отправлять?))

Не делайте рефреш после "не верный". Он уже сделан после "просрочен", просто не успел прийти. Дождитесь окончания рефреша и повторите запрос с новым токеном.


Часовой пояс GMT +3, время: 10:25.