Ques/Help/Req Вы кто такие, я вас не знаю, или Как мы делаем JWT-аутентификацию

XakeR

Member
Регистрация
13.05.2006
Сообщения
1 912
Реакции
0
Баллы
16
Местоположение
Ukraine
Привет! Меня зовут Данил, я backend-разработчик в Doubletapp. Почти во всех наших проектах есть пользователи, которые могут войти в систему. А значит, нам почти всегда нужна авторизация. Мы используем авторизацию, построенную на JSON Web Token. Она отлично сочетает в себе простоту реализации и безопасность для приложений.

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

Вы кто такие, я вас не знаю, или Как мы делаем JWT-аутентификацию0
Вы кто такие, я вас не знаю, или Как мы делаем JWT-аутентификацию1


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

Что такое 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) — уникальный идентификатор токена.

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

Как-то так будет выглядеть полезная нагрузка токена, выписанного на [email protected]:

{ «message»: «something info», «sub»: «[email protected]» }

Или вот так в 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>

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

Вы кто такие, я вас не знаю, или Как мы делаем JWT-аутентификацию2


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

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

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-токен серверу, который его отзывает и возвращает новую пару.

Вы кто такие, я вас не знаю, или Как мы делаем JWT-аутентификацию3


Что будет, если истечет 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-токены. Если Боб заметит подозрительную активность, то сможет нажать на кнопку и отозвать выданные токены.

Вы кто такие, я вас не знаю, или Как мы делаем JWT-аутентификацию4


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

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 нельзя хранить ничего компрометирующего: личные данные пользователей, пароли и тому подобное.


Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста. А вы используете в своих проектах авторизацию? 0% передаю логин и пароль в каждом запросе 0 0% вечный JWT 0 100% access и refresh 1 0% другое 0 Проголосовал 1 пользователь. Воздержавшихся нет.
 

AI G

Moderator
Команда форума
Регистрация
07.09.2023
Сообщения
786
Реакции
2
Баллы
18
Местоположение
Метагалактика
Сайт
golo.pro
Native language | Родной язык
Русский
Sorry I couldn't contact the ChatGPT think tank :(
 
198 114Темы
635 085Сообщения
3 618 401Пользователи
EeOneНовый пользователь
Верх