Привет! Меня зовут Данил, я backend-разработчик в Doubletapp. Почти во всех наших проектах есть пользователи, которые могут войти в систему. А значит, нам почти всегда нужна авторизация. Мы используем авторизацию, построенную на JSON Web Token. Она отлично сочетает в себе простоту реализации и безопасность для приложений.

В интернете есть много разных материалов с объяснением, что такое JWT и как им пользоваться. Но большинство примеров ограничиваются выдачей токена для пользователя. В этой статье я хочу рассказать не только о том, что такое JWT, но и как можно реализовать работу с access и refresh токенами и решить сопутствующие проблемы. Будет немного теории и много практики. Присаживайтесь поудобнее, мы начинаем.

Путеводитель:

Что такое JSON Web Token?
Использование и реализация
Простая реализация JWT
Access и refresh tokens
Как отозвать токены
Доступ с нескольких устройств
Удаление старых данных
Заключение

Что такое JSON Web Token? 

Если вы уже знакомы с технологией JWT, и вам не нужна теория, то вы можете пропустить эту часть и перейти сразу к реализации.

Определение

JWT (JSON Web Token) — это открытый стандарт для создания токенов доступа, основанный на формате JSON. Обычно он используется для передачи данных для аутентификации пользователей в клиент-серверных приложениях. Токены создаются сервером, подписываются секретным ключом и передаются юзеру, который в дальнейшем использует их для подтверждения своей личности.

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

Структура токена

Токен состоит из трех частей: header, payload и signature. Первые две представляют из себя JSON, закодированный при помощи base64. Signature — подпись токена.

Header — заголовок. Он содержит два поля: alg (алгоритм подписи)* и typ (тип токена). В расшифрованном виде он выглядит, например, так:

{
    "alg": "HS256",
    "typ": "JWT"
}

* обычно используется HS256 или RS256, но стандарт предполагает и другие алгоритмы шифрования подписи.

А в зашифрованном виде вот так: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

Payload — полезная нагрузка. Здесь хранится нужная информация о пользователе и токене. Для этого стандартом зарезервированы некоторые ключи, но все они являются необязательными:

  • iss (issuer) — издатель токена;

  • sub (subject) — субъект, которому выдан токен;

  • aud (audience) — получатели, которым предназначается данный токен;

  • exp (expiration time) — время, когда токен станет невалидным;

  • nbf (not before) — время, с которого токен должен считаться действительным;

  • iat (issued at) — время, в которое был выдан токен;

  • jti (JWT ID) — уникальный идентификатор токена.

Помимо этих ключей можно придумать и добавлять любые другие, которые вам нужны.

Как-то так будет выглядеть полезная нагрузка токена, выписанного на befunny@doubletapp.ai:

{
    "message": "something info",
    "sub": "befunny@doubletapp.ai"
}

Или вот так в base64: eyJtZXNzYWdlIjoiSGVsbG8sIEhhYnIhIiwic3ViIjoiYmVmdW5ueUBkb3VibGV0YXBwLmFpIn0=

Важно! Расшифровать токен может кто угодно (например, на сайте jwt.io). Поэтому ни в коем случае нельзя передавать в нем компрометирующую информацию: чувствительные данные пользователей, пароли и прочее.

Signature — сигнатура токена, создаваемая по следующему принципу:

signature = HMAC_SHA256(secret, base64urlEncoding(header) + '.' + base64urlEncoding(payload))

Закодированные при помощи base64 header и payload сцепляются в одну строку при помощи разделителя — точки. Получившуюся строку кодируют при помощи выбранного алгоритма и секретного ключа.

secret — это ключ для шифровки и проверки подписи. Он генерируется и хранится на сервере и используется для подписи токена при генерации. Также он нужен для проверки токена при получении. Важно обеспечить сохранность, то есть недоступность этого ключа. Причина в том, что с его помощью становится возможно создавать любые токены для вашего приложения, которые будут признаны валидными. Основным требованием к ключу является устойчивость к брутфорсу. Обычно он генерируется в формате hex.

Конечный токен представляет из себя строку, состоящую из трех ранее описанных частей, соединенных точками. Согласно примеру выше, токен у нас будет такой: 

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJiZWZ1bm55QGRvdWJsZXRhcHAuYWkiLCJtZXNzYWdlIjoiSGVsbG8sIEhhYnIhIn0.FAMoE435ZafgdICuc6181RsEuR5V1J7dJkzhZRWQk1Y

Почему это работает

Почему такой формат токена гарантирует нам сохранность данных и невозможность их подмены? Фокус в том, что для проверки подлинности токена достаточно взять из него header и payload, получить по ним signature по алгоритму выше и сравнить сигнатуру с той, что реально присутствует в токене.

Недобросовестный пользователь решил докинуть лишнего в свой токен или поменять юзера, которому он был выдан? Токен будет признан недействительным из-за несовпадения фактической и посчитанной signature, запрос будет отклонен сервером.

Использование и реализация 

Дальше я приведу примеры кода на Python 3.10. Для кодирования и декодирования JWT будет использоваться PyJWT, в качестве веб-фреймворка — FastAPI.

Простая реализация JWT 

Простейший сценарий использования JWT-токенов следующий:

пользователь регистрируется/логинится в системе, ему выписывается токен;

этот токен сохраняется на стороне клиента;

каждый следующий свой запрос клиент делает с заголовком:
Authorization: Bearer <token>

сервер, получая запрос с таким заголовком, проверяет его валидность. И в случае успеха отправляет запрашиваемый контент.

Реализуем сценарий:

сгенерируем токен:

token = jwt.encode(payload={'sub': user.id}, key=JWT_SECRET, algorithm='HS256')

проверим его валидность при получении запроса. Это можно вынести в отдельную middleware:

async def check_access_token(
    request: Request,
    authorization_header: str = Security(APIKeyHeader(name='Authorization', auto_error=False))
) -> str:
    # Проверяем, что токен передан
    if authorization_header is None:
        raise JsonHTTPException()

    # Проверяем токен на соответствие форме
    if 'Bearer ' not in authorization_header:
        raise JsonHTTPException()

    # Убираем лишнее из токена
    clear_token = authorization_header.replace('Bearer ', '')

    try:
        # Проверяем валидность токена
        payload = decode(jwt=clear_token, key=JWT_SECRET, algorithms=['HS256', 'RS256'])
    except InvalidTokenError:
        # В случае невалидности возвращаем ошибку
        raise JsonHTTPException()
    
    # Идентифицируем пользователя
    user = await APIUser.filter(id=payload['sub']).first()
    if not user:
        raise JsonHTTPException()

    request.state.user = user

    return authorization_header

Вы можете справедливо спросить: «А зачем нужно дописывать этот Bearer к токену, если он всё равно отбрасывается?» — так исторически сложилось.

Это самый простой способ использования JWT. У него есть как плюсы, так и минусы.

Плюсы

Минусы

Простота

На стороне сервера не нужно ничего хранить.

Токен выдается один раз и навсегда. Например, если Мэллори перехватит токен Боба, то получит вечный доступ к его данным.
Единственный способ отозвать токен Боба — поменять secret. Но при этом сломаются токены всех остальных пользователей.

Access и refresh токены 

Решим проблему с тем, что один токен может использоваться вечно. Для этого изменим схему взаимодействия клиента и сервера. Токены будут двух типов:

Access Token — токен доступа к информации, обычно имеет время жизни несколько минут.
Refresh Token — токен обновления, по которому можно получить новую пару токенов; срок жизни измеряется днями.

Сценарий использования:

  • пользователь регистрируется и получает пару токенов: access и refresh;

  • все свои запросы он сопровождает access-токеном и получает ответ "(как раньше, с обычным jwt-токеном)";

  • когда срок жизни access-токена уже истек или начинает подходить к концу, пользователь (или клиентское приложение) отправляет свой refresh-токен серверу, который его отзывает и возвращает новую пару.

Что будет, если истечет refresh-токен? Пользователю будет нужно пройти авторизацию, чтобы подтвердить свою личность и получить новую пару токенов.

При таком подходе у юзера будет доступ к контенту без постоянной потребности в новой аутентификации. Также ущерб от возможного перехвата access-токена злоумышленником будет относительно невелик из-за малого времени жизни токена.

Иначе говоря, когда Боб зарегистрируется и получит свою пару токенов, приложением он сможет пользоваться без постоянного ввода логина и пароля. Если же вдруг Мэллори перехватит его access-токен, то ее счастье продлится недолго — время жизни токена скоро истечет, а refresh-токена для обновления у нее нет. О том, что будет, если перехватят refresh-токен, расскажем чуть ниже.

Реализация

До этого токены создавались по простой схеме. Сейчас все будет чуть сложнее. Разберем метод подписи токена:

def __sign_token(self,
    type: str, subject: str,
    payload: вict[str, Any]={},
    ttl: timedelta=None
) -> str:
    """
    Keyword arguments:
    type -- тип токена(access/refresh);
    subject -- субъект, на которого выписывается токен;
    payload -- полезная нагрузка, которую хочется добавить в токен;
    ttl -- время жизни токена
    """
    # Берём текущее UNIX время
    current_timestamp = convert_to_timestamp(datetime.now(tz=timezone.utc))
        
    # Собираем полезную нагрузку токена:
    data = dict(
        # Указываем себя в качестве издателя
        iss='befunny@auth_service',
        sub=subject,
        type=type,
        # Рандомно генерируем идентификатор токена ( UUID )
        jti=self.__generate_jti(),
        # Временем выдачи ставим текущее
        iat=current_timestamp, 
        # Временем начала действия токена ставим текущее или то, что было передано в payload
        nbf=payload['nbf'] if payload.get('nbf') else current_timestamp
    )
    # Добавляем exp- время, после которого токен станет невалиден, если был передан ttl
    data.update(dict(exp=data['nbf'] + int(ttl.total_seconds()))) if ttl else None
    # Изначальный payload обновляем получившимся словарём
    payload.update(data)
    
    return jwt.encode(payload=payload, key=JWT_SECRET, algorithm='HS256')

При регистрации возвращаем пользователю пару токенов:

@auth_api.post('/register', response_model=AuthOutput)
async def register(body: AuthInput):
    ...
    
    user = await AuthenticatedUser.create(
        login=body.login,
        password_hash=hash_password(body.password),
    )
    
    access_token = jwt_auth.generate_access_token(subject=user.login)
    refresh_token = jwt_auth.generate_refresh_token(subject=user.login)
    
    return AuthOutput(access_token=access_token, refresh_token=refresh_token)    

Дополним нашу middleware проверкой вида токена, чтобы убрать возможность пользоваться refresh-токеном в качестве access-токена:

try:
  payload = ...
  if payload['type'] != TokenType.ACCESS.value:
    raise JsonHTTPException()
except InvalidTokenError:
  ...

Добавим ручку для обновления токенов:

async def update_tokens(self, user: APIUser, refresh_token: str) -> ...:
  payload, error = try_decode_token(jwt_auth=self._jwt_auth, token=refresh_token) 
  if error:
    return ...
  
  if payload['type'] != TokenType.REFRESH.value:
    return ...
  
  access_token, refresh_token = self._issue_tokens_for_user(user, device_id)
  return Tokens(access_token=access_token, refresh_token=refresh_token)

Вуаля! У нас появилось сильно больше кода и немного больше безопасности. 

Однако возможность получить безграничный доступ к чужим данным у злоумышленника никуда не делась. Просто теперь Мэллори нужно перехватить refresh-токен, а не access. В таком случае у нее снова будет вечный доступ к контенту Боба, какой был и при обычном JWT.

Как отозвать токены 

Решим проблему и научимся отзывать refresh-токены. Если Боб заметит подозрительную активность, то сможет нажать на кнопку и отозвать выданные токены.

Для этого в базе нужно будет создать табличку с данными о токене: его идентификаторе, владельце и статусе (отозван или нет):

class IssuedJWTToken(Model):
  jti = fields.CharField(max_length=36, pk=True) 
  subject = fields.ForeignKeyField(model_name='models.APIUser', on_delete='CASCADE', related_name='tokens')
  revoked = fields.BooleanField(default=False)

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

Реализация

Сохраняем jti нового refresh-токена при регистрации и обновлении токенов:

await IssuedToken.create(subject=user, jti=jwt_auth.get_jti(refresh_token))

При обновлении отзываем все выпущенные на пользователя refresh-токены:

payload, _ = try_decode_token(jwt_auth, body.refresh_token)
await IssuedToken.filter(jti=payload['jti']).update(revoked=True)

На этом можно было бы и остановиться. Однако представим следующую ситуацию:

  • у Боба есть refresh_token_1, который был украден;

  • Боб использует refresh_token_1, чтобы получить новую пару токенов;

  • сервис возвращает refresh_token_2 и access_token_2, отзывая при этом предыдущие;

  • Мэллори тоже пытается использовать refresh_token_1, чтобы получить для себя пару новых токенов и беспрепятственно пользоваться приложением от имени Боба.

Мэллори увидит на экране ошибку. Но что, если бы первый токен обновила Мэллори? На уровне приложения мы не можем узнать наверняка, кто из них первым обновил токены и кто сейчас юзает приложение — пользователь или злоумышленник. Поэтому нам не стоит забывать о безопасности, если мы знаем, что токен был украден.

Auth0 предлагает следующее: если refresh-токен используется повторно, то нужно немедленно сделать недействительным все семейство этих токенов.

Последующие шаги будут такие:

  • сервис распознает, что refresh_token_1 используется повторно, и немедленно делает недействительным все семейство refresh-токенов, включая refresh_token_2;

  • сервис отправляет Мэллори ответ об отказе в доступе;

  • срок действия access_token_2 истекает, и Боб пытается использовать refresh_token_2 для получения новой пары токенов. Сервис отказывает ему в доступе. Нужна повторная аутентификация.

Реализуем эту схему, добавив одно условие в логику ручки для обновления токенов:

if await check_revoked(payload['jti']):
  await IssuedJWTToken.filter(jti=payload['jti']).update(revoked=True)
  return None, AccessError.get_token_already_revoked_error()

Логика этого обработчика будет почти идентична любому отзыву токенов, что мы делали раньше.

Теперь у нас есть возможность отозвать refresh-токен при подозрительной активности.

Почему мы отзываем только refresh-токен? Отзывать access-токен не всегда нужно, так как его время жизни измеряется минутами. Однако для большей безопасности добавим возможность отзывать и access-токены.

Сохраняем данные об access-токене в IssuedJWTToken по аналогии с refresh-токеном.

В местах, где мы отзываем токены, меняем filter(jti=jti) на filter(subject=user), чтобы получилось так:

await IssuedToken.filter(subject=user).update(revoked=True)

Добавим в нашу middleware проверку на то, был ли отозван токен:

if await check_revoked(payload['jti']):
  raise JsonHTTPException(content=dict(AccessError.get_token_revoked_error()), status_code=403)

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

Мэллори перехватила access-токен и начала рассылать непристойности от имени Боба? Не страшно, в крайнем случае она будет использовать его несколько минут, после чего потеряет доступ. Если же Боб замечает подозрительную активность быстро, то сможет отозвать все выпущенные токены еще раньше.

Мэллори перехватила refresh-токен? Тоже не проблема. В один момент Мэллори и Боб попробуют обновиться по одному и тому же refresh-токену и перейдут на экран входа в приложение. Боб перелогинится, а Мэллори останется ни с чем.

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

Доступ с нескольких устройств 

Все описанное выше прекрасно работает, если не учитывать, что у пользователя может быть несколько устройств. И приложение не сможет отличить Мэллори от Боба, который решил зайти на наш сайт с телефона. В нынешнем варианте авторизация на одном устройстве поломает токены на другом, что не очень user-friendly.

Начнем дополнительно хранить уникальный идентификатор устройства — device_id.

Обновим нашу таблицу в базе данных:

class IssuedJWTToken(Model): 
  jti = ... 
  subject = ... 
  device_id = fields.CharField(max_length=36)
  revoked = ...

Будем добавлять этот идентификатор в payload токена. При обновлении токенов уточним фильтрацию для отзыва:

device_id = payload['device_id'] 
await IssuedJWTToken.filter(subject=user, device_id=device_id).update(revoked=True)

Однако при попытке обновить отозванный токен ломаться должны по-прежнему все. Для удобства в коде middleware вместе с сохранением пользователя будем сохранять и device_id:

request.state.device_id = payload['device_id']

Теперь мы добавили возможность разлогиниться с одного конкретного устройства. Для этого нужно будет всего лишь отозвать все выпущенные на конкретное устройство токены, как мы делали это при обновлении.

Может возникнуть справедливый вопрос: «А где брать этот device_id?» Можно сгенерировать UUID. Это самый простой вариант. Но в случае чего в качестве идентификатора могут выступать, например, идентификатор IDFA (iOS) или идентификатор объявления (Android).

Удаление старых данных 

С каждой новой фичей мы все больше и больше нагружали нашу базу. Теперь же вспомним, что нам не нужно хранить записи по токенам, которые уже протухли. Что с этим делать?

Хранить в IssuedJWTToken время протухания:

class IssuedJWTToken(Model): 
  subject = ... 
  jti = ... 
  device_id = ... 
  revoked = ... 
  expired_time = fields.IntField() # не забываем, что это UNIX-время.

Раз в какое-то время удалять все записи, чье время протухания наступило. Сделать это можно с помощью любого планировщика и следующих строк:

current_timestamp = convert_to_timestamp(datetime.now(tz=timezone.utc)) 
await IssuedJWTToken.filter(expired_time__lt=current_timestamp).delete()

Гитхаб с реализацией всего вышеописанного: github.com/doubletapp/habr-jwt-auth-example

Полезные ссылки:

  • RFC7519 — стандарт JSON Web Token;

  • auth0.com — документация о том, как auth0 использует JWT в целях разработки платформы для идентификации пользователей.

Заключение 

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

JWT — удобный инструмент для проверки аутентификации пользователя. Основные отличия от других токенов в том, что его очень просто расшифровать и посмотреть, что внутри. При этом его невозможно подделать без знания ключа благодаря подписи. Однако отметим, что эти данные может раскодировать кто угодно. Поэтому в JWT нельзя хранить ничего компрометирующего: личные данные пользователей, пароли и тому подобное.

Комментарии (40)


  1. bogolt
    30.09.2023 12:21
    +7

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


    1. ultrinfaern
      30.09.2023 12:21
      +3

      Так этот вопрос нужно не автору задавать, а тем, кто придумал стандарт OAUTH.

      Да и вообще сам OAUTH разрабатывался для распределнных систем, где у одной ресурсной системы доступа к аутентификацонной системы может и не быть. И если смотреть это как на замену кук, да, оно выглядит странно и переусложненно.


      1. bogolt
        30.09.2023 12:21
        +1

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


        1. YegorP
          30.09.2023 12:21
          +1

          Токены это не альтернатива кукам, а ортогональный вопрос. С куками вы точно так же выбираете что в них будет, и это вполне могут быть токены. И тогда вся статья справедлива и для куков с чисто техническими поправками.


    1. YegorP
      30.09.2023 12:21
      +1

      Подразумевается, что рефреш-токен перехватить сложнее, т.к. он отправляется только на один эндпоинт и может быть даже на отдельный сервер (сервер авторизации), и не обязательно тем же методом, что токен доступа. Если делать отзываемым непосредственно токен доступа (без рефреша), то механизм отзыва должен быть реализован каждым сервисом, и это усложнение в мультисервисных архитектурах может быть покруче схемы с рефрешем.


      1. mozg3000tm
        30.09.2023 12:21

        Добавлю что рефреш токен безопасно хранить как Куку с настройкой http only, что ограничивает возможность доступа к ней из js. А поскольку соединение https, то и возможность перехватить её обнуляется. Этот токен становится не доступен для перехвата.


        1. mayorovp
          30.09.2023 12:21

          Это чтобы злоумышленник мог отправить refresh-запрос и браузер сам подставил ему туда валидный токен?..


          1. mozg3000tm
            30.09.2023 12:21

            Да, чтобы вымышленный злоумышленник супер злодей мог обновить токен доступа (access). А классический автологин по куке разве не так работает?


            1. mayorovp
              30.09.2023 12:21
              +2

              Да, и украсть этот самый токен.


              Что вы понимаете под классическим автологином по куке — я не знаю, но обычно в классической схеме куки защищены запретом CORS. В "современной" же схеме, api разрешено использовать любому у кого есть токен доступа.


              Собственно, вы же сами пишете ниже — "в статье рассказывается как сделать общедоступное апи, а не замкнутое на личный SPA". Такое API ни в коем случае не должно использовать куки.


              1. mozg3000tm
                30.09.2023 12:21

                Такое API ни в коем случае не должно использовать куки.

                Это вряд ли. Я думал в моём апи нет кук, но оказалось что всякие сторонние штуки типа метрик, онлайн-чатов и пр. добавляют на сайт куки и они как минимум есть. Вообще куки относятся к клиенту (как я это понимаю), это его фишка, а не сервера (в конечном счёте это просто специальный заголовок в запросе). Чего точно НЕ ДОЛЖНО БЫТЬ так это СЕССИЙ.

                А вообще, в статье часть с реализацией т.н. JWT аутентификации это вообщем-то просто Bearer авторизация и спецефически жеветэшного тут то, что JWT можно не хранить на целевом (апликейшэн) сервере, но в статье есть реализация где его хранят, чтобы иметь возможноть его отозвать. Собсвенно статья (по крайней мере в части реализации) скорее про Bearer авторизацию с особенностью jwt.

                куки защищены запретом CORS

                В обще доступном апи наверное сложно задать конретный origin для запросов, но для самого рефрешь токена следует указать параметры path=/api/v1/auth/refresh & domain=api-site.ru

                Не знаю как это зашитит от того о чём вы говорили изначально, но такая реализация вроде широкоизвестна в контексте Beaerer авторизации. Jwt это частный случай токена, который можно подставить в неё.


                1. mayorovp
                  30.09.2023 12:21

                  Не знаю как это защитит от того о чём вы говорили изначально

                  Никак. От CSRF можно защититься только анти-CSRF-токеном или политикой CORS. Для API работает только второй вариант (и то не всегда). Если нет возможности использовать CORS — остаётся только одно, не хранить ничего влияющего на безопасность в куках.


                1. funca
                  30.09.2023 12:21

                  Вообще куки относятся к клиенту (как я это понимаю), это его фишка

                  Есть клиентские и есть серверные. Но в целом, сторона обычно их ставит для собственных нужд, а не противоположной. HttpOnly как пример серверных кук. Клиент с уровня приложения их как бы "не видит", но на уровне протокола - обязан возвращать при каждом запросе. Сервер может их потом использовать для идентификации клиентов, поддержки observability, авторизации и т.п.


                  1. mozg3000tm
                    30.09.2023 12:21

                    Есть клиентские и есть серверные

                    Вы конечно правы, в том смысле, что бекэндер может установить Куку на клиенте. Но сервер не шлёт запросов клиенту (по протоколу http), поэтому он и не устанавливает куки. Клиент устанавливает куки, потому что именно он шлёт запросы.

                    Клиент с уровня приложения их как бы "не видит",

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


  1. David_Osipov
    30.09.2023 12:21
    -3

    Пожалуйста, не используйте чистый JWT для пользовательский аутентификации. Токен хранится в Local storage и может быть похищен вообще всем, что может дотянуться до Local storage + привет CSRF атаки.

    Передавайте JWT в cookie с флагами безопасности и префиксами типа __Secure-ваш_куки, если нужно. JWT никогда не были предназначены для пользовательский аутентификации и поэтому браузер их не защищает так как куки.


    1. mayorovp
      30.09.2023 12:21
      +1

      Вот как раз от CSRF токен, хранящийся в Local storage, защищён.


    1. bogolt
      30.09.2023 12:21

      куки и жвт это не взаимоисключающие вещи. Куки это метод хранения и передачи данных, а жвт формат данных.



    1. funca
      30.09.2023 12:21

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


      1. mozg3000tm
        30.09.2023 12:21

        В рест апи не используются сессии, поэтому тут нет такой дилеммы и апи по настоящему без сохранения состояния. И JWT именно что для постоянной авторизации для апи без сохранения состояния.


        1. mozg3000tm
          30.09.2023 12:21
          +1

          Есть реализации SPA где используют сессии и прочее, но в статье рассказывается как сделать общедоступное апи, а не замкнутое на личный SPA.


        1. noavarice
          30.09.2023 12:21
          +1

          О каком состоянии речь? Если данные аутентификации на бэке это состояние, то и БД это состояние?
          Есть first-party аутентификация (как в вашем случае), с конкретными требованиями (возможность разлогина, таймауты, возможность посмотреть сколько у тебя сессий активных и т.д.). Есть third-party аутентификация (для этого придумали OAuth и JWT), с конкретными требованиями (делегирования доступа одних приложений к данным пользователей в других). JWT это инструмент, решающий другие задачи, противоположные вашей.
          Я считаю, что ваша статья приносит больше вреда, чем пользы, потому что люди прочитают ее и будут дальше придумывать собственные алгоритмы аутентификации, что чревато. Секьюрити один из тех случаев, где лучше всегда взять готовое, если оно есть


          1. mozg3000tm
            30.09.2023 12:21

            О каком состоянии речь?

            О том самом в каком протокол http - stateless.


          1. mozg3000tm
            30.09.2023 12:21

            Я считаю, что ваша статья

            Вы ответили на мое сообщение, но статья не моя. Я считаю, что это не выдумка автора, а известная тема.


        1. funca
          30.09.2023 12:21

          В рест апи не используются сессии

          Это ограничение касается только уровня приложения: клиент-серверное приложение не должно хранить свое состояние на уровня транспорта. Иначе будут такие несуразности как зависимость результата от порядка запросов, невозможность кеширования и т.п.

          Механизмы авторизации есть смысл оставлять за скобками. Т.е сначала авторизация, а внутри уже ReST - так чтобы приложение даже не подозревало. В таком случае одно другому не мешает. Для авторизации наоборот есть даже требования со стороны аудит логгинга и SIEM трекать пользовательские сессии, от момента активации и до конца.


          1. mozg3000tm
            30.09.2023 12:21

            Это ограничение касается только уровня приложения

            Вообще, это определение REST API. Оно stateless.

            клиент-серверное приложение не должно хранить свое состояние на уровня транспорта.

            Http протокол для stateless клиент серверного взаимодействия. Что значит хранить на транспортном уровне мне не хватает воображения что предоставить это.


  1. savostin
    30.09.2023 12:21
    +5

    Вы таким образом, храня на сервере все, убили половину плюсов, оставив только «простоту». А нет, ее тоже убили.


  1. grisha0088
    30.09.2023 12:21
    +2

    # Проверяем валидность токена
            payload = decode(jwt=clear_token, key=JWT_SECRET, algorithms=['HS256', 'RS256'])

    Код как бы говорит, что для того, чтобы декодировать токен, нужно передать туда ключ JWT_SECRET. Но это же не так, ключ нужен только чтобы проверить подпись токена.
    Получается метод проверяет и декодирует токен, и лучше бы его назвать validateAndDecode. А глядя на это имя кажется, тут еще лучше было бы разделить метод на два.

    Также мне не совсем понятно, как вы в реальности делаете авторизацию. У вас монолит или микросервисы? Если микросервисы, то они все получается знают ключ?
    Вы не думали вынести логику генерации токена в отдельный сервис и перейти на схему с ассиметричным шифрованием?


    1. funca
      30.09.2023 12:21
      +1

      Но это же не так, ключ нужен только чтобы проверить подпись токена.

      Есть два типа JWT токенов: JWS, в котором данные только подписаны и JWE, где они ещё и зашифрованы. Возможно автор использует второй вариант.


    1. mozg3000tm
      30.09.2023 12:21

      Я так понял что JWT не декодируется.
      Тут как с паролем да и вообще с хеш суммой - сравнивабются результаты преобразования, а не исходные значения.
      Подписывается то что (header + payload) пришло и сравнивается с частью токена которая подпись, и если они совпадают, то все ок.
      Все остальные утверждения (claims) типа срока действия, издателя и пр. берутся из первой части токена, которая Claims.


  1. p-oleg
    30.09.2023 12:21

    • когда срок жизни access-токена уже истек или начинает подходить к концу, пользователь (или клиентское приложение) отправляет свой refresh-токен серверу, который его отзывает и возвращает новую пару.

    Вот этот абзац не очень понятен (мне по крайней мере). Зачем отзывать refresh токен, если его время жизни не истекло? Или под новой парой подразумеватся этот же refresh-токен и новый access токен?


    1. mozg3000tm
      30.09.2023 12:21

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


      1. p-oleg
        30.09.2023 12:21

        Т.е. refresh и access токены новые оба, но у нового refresh токена время жизни уменьшилось на соответствующее значение?


        1. mozg3000tm
          30.09.2023 12:21

          У рефрешь токена время жизни тоже самое. За счёт этого оно продлевается автоматически.


  1. andrettv
    30.09.2023 12:21

    Почему заголовок про аутентификацию, а всё остальное - про авторизацию? Ведь перед тем, как предоставить доступ, пользователя нужно сначала идентифицировать, аутентифицировать, определить полномочия в системе и только потом организовать сессию. Для аутентификации средствами OAuth2 нужно реализовать ещё один поток (OIDC) с ID-токеном (он тоже JWT). Неплохое введение в картинках есть, например, на https://habr.com/ru/companies/flant/articles/475942/.

    Про безопасность JWT можно почитать на https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html.


    1. ultrinfaern
      30.09.2023 12:21

      Аутентификация происходит до выдачи любых токенов. Сам ид-токен нужен для аутентификации на третьих системах. Если их нет то и токен такой не нужен. А авторизация происходит по аксесс токену.


  1. barby
    30.09.2023 12:21

    Важно! Расшифровать токен может кто угодно

    Ну это не совсем правда. Второй абзац того же jwt.io: Although JWTs can be encrypted to also provide secrecy between parties, we will focus on signed tokens...


  1. BigLY
    30.09.2023 12:21
    +1

    Прочитав статью у меня появились вопросы к реализации.

    Создав middleware вы заблокировали доступ к url с OpenApi по адресу «/docs», но это лишь часть беды. Как вы получаете доступ к регистрации пользователя, если перед созданием токенов для пользователя, в этой же middleware, идет проверка на наличие токена? Есть какая-то админская учетка, благодаря которой идет регистрация пользователя? Но тогда в чем идея, если каждый зарегистрированный пользователь может создавать других пользователей?


    1. BeFunny Автор
      30.09.2023 12:21

      На самом деле, мы ничего не заблокировали. check_access_token, демонстрируемый в статье, выступает в роли зависимости, которую в рамках фреймворка FastAPI можно устанавливать на разные области видимости: конкретный обработчик, роутер или же всё приложение. У нас она установлена на роутер /users, поэтому, auth-обработчики токена не требуют.
      Примерно такая же ситуация и с документацией (/docs), о которой вы пишете. Это изолированный путь, существовать которому мы никак не мешаем.
      Всё это можно посмотреть и попробовать лично, склонировав наш репозиторий. Там всё контейнерезировано и с запуском приложения не должно возникнуть никаких проблем.


      1. BigLY
        30.09.2023 12:21

        Отлично, что по моим вопросам уже есть решение, вероятно стоило описать это в статье, тогда некоторые вопросы сразу бы отпали


  1. fidgethard
    30.09.2023 12:21

    nbf (not before) — время, с которого токен должен считаться действительным;

    Однажды столкнулся с ситуацией, когда 2 сервера бэкенда рассинхронизировались по времени на 5 секунд. Сервер, который выдает токен, и сервер, который читает. После чего все запросы с токеном стали падать.Правильный путь - поправить синхронизацию серверного времени. Костыль - библиотеки для работы JWT могут содержать переопределяемый параметр leeway, т.е. отсрочку от указанного в токене времени.