OAuth 2.0: что это и как работает протокол авторизации

Смартфон с экраном подтверждения входа рядом с ноутбуком Технологии

OAuth 2.0 описывает, как приложение получает ограниченный доступ к данным пользователя в другом сервисе, не узнавая его пароль. Вместо логина и пароля оно получает от сервера авторизации токен доступа: значение с набором разрешений и сроком действия, которое API сервиса примет вместо учётных данных.

Смартфон с экраном подтверждения входа рядом с ноутбуком

Что такое OAuth 2.0 простыми словами

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

С OAuth 2.0 пользователь вводит пароль только на странице облачного сервиса. Тот спрашивает, разрешить ли сервису печати чтение фотографий, и после согласия выдаёт ему токен доступа, который позволяет читать альбом и ничего больше. У токена ограниченный срок жизни, и его можно отозвать, не трогая пароль.

По такому принципу работают кнопки «Войти через Google» и «Войти с Яндекс ID»: API Google и API Яндекс ID используют протокол OAuth 2.0. Спецификация вышла в RFC 6749 в октябре 2012 года и заменила OAuth 1.0. Обратной совместимости между версиями нет, реализация первой версии с OAuth 2 работать не будет.

OAuth 2.0 и OpenID Connect: авторизация или аутентификация

OAuth 2.0 отвечает на вопрос «что приложению можно делать от имени пользователя». Токен доступа предназначен серверу ресурсов, а для клиентского приложения он обычно непрозрачная строка. Чтобы узнать, кто именно вошёл, нужен OpenID Connect.

OpenID Connect (OIDC) добавляет поверх OAuth 2.0 слой аутентификации. Приложение запрашивает scope openid и вместе с токеном доступа получает ID token: JWT с подписью, который содержит идентификатор пользователя у этого провайдера (sub), сведения о том, кто его выдал и для какого клиента. Без openid в scope поведение сервера не определено.

OAuth 2.0 OpenID Connect
Задача авторизация: доступ к API аутентификация: кто вошёл
Что получает клиент access token, иногда refresh token то же плюс ID token
Формат главного токена не задан, обычно непрозрачная строка ID token всегда JWT с подписью
Информация о пользователе через API сервиса в ID token и через эндпоинт UserInfo
Защита от CSRF и повтора state, PKCE плюс nonce в ID token

Если нужен вход на сайт через внешний аккаунт, лучше взять OIDC. Если нужно читать почту, календарь или файлы пользователя, хватит OAuth 2.0.

Роли в OAuth 2.0

Спецификация OAuth 2.0 определяет четыре роли:

  • владелец ресурса (resource owner), тот, кто может предоставить доступ к данным; если это человек, его называют конечным пользователем;
  • клиент (client), приложение, которое получает доступ к данным от имени владельца и с его разрешения: серверное веб-приложение, SPA в браузере, мобильное или десктопное приложение;
  • сервер авторизации (authorization server), он проверяет пользователя, запрашивает согласие и выдаёт токены;
  • сервер ресурсов (resource server), API, где лежат данные; он принимает запросы с токеном доступа.

Сервер авторизации и сервер ресурсов могут быть одной системой или работать на разных машинах. Один сервер авторизации может обслуживать несколько API.

Клиенты делятся на два вида. Конфиденциальный клиент умеет хранить секрет: например, приложение на сервере, к которому у посторонних нет доступа. Публичный не умеет: SPA, мобильные и десктопные приложения выполняются на устройстве пользователя.

Регистрация приложения у провайдера OAuth

Разработчик заполняет форму регистрации приложения в консоли провайдера

До первого запроса авторизации клиент регистрируется у провайдера, обычно через форму в консоли разработчика. Вы указываете вид клиента и redirect URI, адрес, куда сервер вернёт пользователя после входа. В ответ провайдер предоставляет:

  • client_id, уникальный идентификатор приложения; он не секрет и доступен пользователю, поэтому одного client_id для проверки клиента недостаточно;
  • client_secret, секретный ключ для конфиденциальных клиентов; держите его на сервере, в переменных окружения или хранилище секретов, но не во фронтенде.
Читайте также:  REST API

Провайдер должен сравнивать redirect URI из запроса авторизации с зарегистрированным посимвольно. Исключение одно: порт у адреса localhost в нативных приложениях. Лишний слеш в конце или http вместо https дадут ошибку, и это правильное поведение: так код авторизации не уйдёт на чужой адрес.

Как работает OAuth 2.0: Authorization Code по шагам

Authorization Code (код авторизации) сейчас используется как основной поток OAuth 2.0 для приложений, где есть пользователь. По шагам:

  1. Приложение перенаправляет пользователя на страницу авторизации провайдера с параметрами response_type=code, client_id, redirect_uri, scope, state и, с PKCE, code_challenge.
  2. Пользователь входит в свой аккаунт на стороне провайдера и видит запрос на разрешение доступа к своим данным. Пароль клиент не видит.
  3. После согласия провайдер возвращает браузер на redirect URI с одноразовым кодом и тем же state в строке запроса. Код авторизации живёт недолго, рекомендуемый максимум 10 минут.
  4. Приложение отправляет POST-запрос на эндпоинт токена: grant_type=authorization_code, код, redirect_uri, client_id и code_verifier, а конфиденциальный клиент ещё и свой секрет. Тело запроса передаётся в формате application/x-www-form-urlencoded.
  5. Сервер авторизации проверяет код и возвращает JSON: access_token, token_type, expires_in в секундах и, если сервер их выдаёт, refresh_token.
  6. Приложение обращается к API с заголовком Authorization: Bearer <токен>.

Оба адреса сервера авторизации работают только по HTTPS: через них передаются учётные данные и токены. Код авторизации привязан к client_id и redirect URI, использовать его можно один раз. Если код пришёл повторно, сервер обязан отказать и по возможности отозвать токены, выданные по этому коду.

Схема обмена сообщениями на маркерной доске: вертикальные линии и стрелки между ними

PKCE: как сгенерировать code_verifier и code_challenge

PKCE (Proof Key for Code Exchange) придумали для защиты от перехвата кода авторизации. В мобильных приложениях redirect URI может вести на свою схему вроде myapp://callback, и вредоносное приложение может зарегистрироваться обработчиком той же схемы. Без этой защиты перехваченного кода хватает, чтобы получить токен.

С PKCE клиент перед каждым запросом авторизации создаёт случайный ключ code_verifier длиной 43-128 символов из латинских букв, цифр и знаков -._~. В запрос авторизации уходит только его хеш SHA-256 в base64url, code_challenge. Сам ключ клиент отправляет позже, при обмене кода на токен, и сервер сверяет хеш. Перехватчик видит код и хеш, но ключа не знает. Подобрать ключ по результату хеш-функции на практике нельзя: он случайный и достаточно длинный для того, чтобы перебор не имел смысла.

# Python 3.14, только стандартная библиотека
import base64
import hashlib
import secrets

def make_verifier():
    # 32 случайных байта в base64url без "=" дают 43 символа
    return secrets.token_urlsafe(32)

def make_challenge(value):
    digest = hashlib.sha256(value.encode("ascii")).digest()
    return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")

v = make_verifier()
print(len(v), len(make_challenge(v)))
# контрольный пример из RFC 7636
print(make_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"))

Вывод:

43 43
E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM

Вторая строка совпадает с эталонным значением из контрольного примера, значит, функция считает правильно. Если использовать base64.b64encode вместо urlsafe_b64encode, на том же примере получится E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw+cM=: плюс вместо дефиса и знак = в конце. С вычисленным на сервере значением такой результат не совпадёт. Метод plain, где хеш не считают и отправляют ключ как есть, остался для совместимости с уже работающими реализациями, в новых его применять не следует; если клиент умеет S256, он обязан использовать S256. Параметр code_challenge_method=S256 не пропускайте: без него сервер считает метод равным plain.

В рабочем проекте ключ и state создают заново для каждого входа, например make_verifier() и secrets.token_urlsafe(16), и хранят в сессии пользователя до возврата с сервера авторизации. Постоянное значение сводит защиту на нет: challenge обязан быть своим у каждого запроса.

Смартфон на столе рядом с ноутбуком, на экране телефона пустая форма входа

Разбор ответа OAuth-сервера: код, state и ошибки

Лупа над распечаткой с кодом на рабочем столе разработчика

На redirect URI приходят code и state, а в случае отказа или неверного запроса параметр error. Перед обменом кода авторизации проверьте state. Он нужен для защиты от CSRF-атак, при которых злоумышленник через подложную ссылку отправляет на ваш адрес свой код, и приложение работает с ресурсами атакующего вместо ресурсов жертвы. Опираться только на PKCE можно, если вы точно знаете, что сервер его проверяет. Если не уверены, сравнивайте state.

from urllib.parse import urlparse, parse_qs

REDIRECT_URI = "https://app.example.com/callback"   # примерный адрес

def read_callback(url, expected_state):
    query = {k: v[0] for k, v in parse_qs(urlparse(url).query).items()}
    if "error" in query:
        raise PermissionError(query["error"])
    if query.get("state") != expected_state:
        raise ValueError("state не совпал, запрос не наш")
    return query["code"]

print(read_callback(REDIRECT_URI + "?code=SplxlOBeZQQYbYS6WxSbIA&state=xyz", "xyz"))
for url in (REDIRECT_URI + "?code=SplxlOBeZQQYbYS6WxSbIA&state=evil",
            REDIRECT_URI + "?error=access_denied&state=xyz"):
    try:
        read_callback(url, "xyz")
    except (ValueError, PermissionError) as e:
        print(type(e).__name__, e)

Вывод:

SplxlOBeZQQYbYS6WxSbIA
ValueError state не совпал, запрос не наш
PermissionError access_denied

Ошибку проверяем раньше, чем state: в ответе с error кода нет, и обращение к query["code"] упало бы с KeyError. access_denied значит, что пользователь или сервер отказали в доступе. Покажите понятное сообщение и не повторяйте запрос автоматически.

Читайте также:  Что такое фреймворк

Обмен кода на токен: учебный сервер на Python

Чтобы увидеть принцип работы сервера авторизации на этапе обмена, поднимем его в том же процессе на http.server. Он принимает POST /token, выдаёт и обновляет токены, данные хранит в словарях в памяти. Стенд нужен для понимания протокола OAuth. В продакшене свой сервер авторизации писать не стоит: используйте OAuth-провайдера или готовый сервер с открытым кодом, например Keycloak, он поддерживает OAuth 2.0, OpenID Connect и SAML.

Рука протягивает бумажный номерок через стойку гардероба

import json
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

CLIENT_ID = "demo-client"                                       # примерное значение
DEMO_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"   # из RFC 7636
GRANTS = {c: {"client_id": CLIENT_ID, "redirect_uri": REDIRECT_URI,
              "challenge": make_challenge(DEMO_VERIFIER)}
          for c in ("SplxlOBeZQQYbYS6WxSbIA", "Qx7pWm2LkT9vRc4YbN8sZA")}
REFRESH = {}

def issue(form):
    if form.get("grant_type") == "authorization_code":
        grant = GRANTS.pop(form.get("code"), None)              # одноразовый
        ok = grant and (grant["client_id"], grant["redirect_uri"], grant["challenge"]) == (
            form.get("client_id"), form.get("redirect_uri"),
            make_challenge(form.get("code_verifier", "")))
    elif form.get("grant_type") == "refresh_token":
        grant = REFRESH.pop(form.get("refresh_token"), None)    # старый больше не примут
        ok = grant and grant["client_id"] == form.get("client_id")
    else:
        return 400, {"error": "unsupported_grant_type"}
    if not ok:
        return 400, {"error": "invalid_grant"}
    refresh = secrets.token_urlsafe(32)
    REFRESH[refresh] = grant
    return 200, {"access_token": secrets.token_urlsafe(32), "token_type": "Bearer",
                 "expires_in": 900, "refresh_token": refresh}

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        raw = self.rfile.read(int(self.headers["Content-Length"])).decode()
        status, body = issue({k: v[0] for k, v in parse_qs(raw).items()})
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(json.dumps(body).encode())

    def log_message(self, *args):
        pass

server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
TOKEN_URL = f"http://127.0.0.1:{server.server_port}/token"

Порт 0 значит «любой свободный», поэтому пример не конфликтует с другими программами. GRANTS.pop удаляет код авторизации при первой же попытке обмена, удачной или нет, и код становится одноразовым. Срок expires_in в 900 секунд выбран для примера: сколько он действует в реальной системе, решает провайдер.

Теперь клиент. urlopen с параметром data отправляет POST и сам ставит заголовок Content-Type: application/x-www-form-urlencoded. Ответы с ошибкой он выбрасывает как HTTPError, их тоже читаем как JSON.

from urllib.parse import urlencode
from urllib.request import urlopen
from urllib.error import HTTPError

def post(form):
    try:
        with urlopen(TOKEN_URL, data=urlencode(form).encode()) as resp:
            return resp.status, json.load(resp)
    except HTTPError as err:
        return err.code, json.load(err)

exchange = {"grant_type": "authorization_code", "code": "SplxlOBeZQQYbYS6WxSbIA",
            "redirect_uri": REDIRECT_URI, "client_id": CLIENT_ID,
            "code_verifier": DEMO_VERIFIER}
status, pair = post(exchange)
print("1.", status, pair["token_type"], pair["expires_in"], len(pair["access_token"]))
print("2.", post(exchange))                          # тот же код второй раз
stolen = dict(exchange, code="Qx7pWm2LkT9vRc4YbN8sZA", code_verifier=make_verifier())
print("3.", post(stolen))                            # перехват, чужой ключ
renew = {"grant_type": "refresh_token", "refresh_token": pair["refresh_token"],
         "client_id": CLIENT_ID}
status, fresh = post(renew)
print("4.", status, fresh["refresh_token"] != pair["refresh_token"])
print("5.", post(renew))                             # старый refresh token

Вывод на Python 3.14.3:

1. 200 Bearer 900 43
2. (400, {'error': 'invalid_grant'})
3. (400, {'error': 'invalid_grant'})
4. 200 True
5. (400, {'error': 'invalid_grant'})

Значения случайные, поэтому печатаем тип, срок и длину. Строки по порядку:

  1. Обмен прошёл: ключ из запроса на токен дал тот же хеш, что пришёл в запросе авторизации.
  2. Тот же код второй раз: invalid_grant. Тем же кодом ошибки сервер отвечает на неверный redirect_uri, чужой client_id и истёкший код.
  3. Атакующий перехватил второй код, но ключ у него свой. Хеш не совпал, обмен отклонён.
  4. Обновление выдало новую пару, и токен обновления в ней другой.
  5. Старый токен обновления после ротации не принимается.

Ноутбук с терминалом, рядом блокнот и карандаш

Токены OAuth: access token и refresh token

Токен доступа (access token) приложение отправляет в API с каждым запросом. Внутри может быть просто идентификатор записи на сервере, а может быть самодостаточная запись с данными и подписью, например JWT. Время жизни приходит в expires_in в секундах, 3600 значит один час с момента ответа. Формат определяет провайдер, поэтому разбирать токен в клиенте не стоит: приложение просто передаёт его в API.

Передают его в заголовке Authorization: Bearer .... В строку запроса URL его не кладут: адреса оседают в истории браузера и логах веб-сервера. Как такой заголовок принимает сервер, мы разбирали в статье о REST API. На просроченный или отозванный токен сервер ресурсов отвечает 401 с invalid_token, на нехватку прав 403 с insufficient_scope.

Токен обновления (refresh token) позволяет получить новый токен доступа без участия пользователя, когда старый истёк. Его отправляют только на сервер авторизации, в API он не ходит. Выдавать ли его вообще, решает провайдер. В ответ на запрос Client Credentials его не включают. У провайдера бывают свои лимиты: у Google на один аккаунт и один client ID действует не больше 100 refresh token, и при выдаче нового сверх лимита самый старый без предупреждения перестаёт работать.

Читайте также:  GraphQL

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

Пластиковая ключ-карта и песочные часы на столе

Scope: какой доступ получает приложение

Scope (область доступа) перечисляет права через пробел: profile email. Значения чувствительны к регистру и определяются сервером авторизации, поэтому список берут из документации провайдера. Пользователь видит на экране согласия, к чему приложение запрашивает доступ, а выданный доступ ограничен этими рамками.

Запрашивайте только те права, которые нужны приложению для конкретного сценария. Приложению, которое показывает аватар, не нужен доступ на запись в файлы.

Grant types: какой поток OAuth 2.0 выбрать

Спецификация OAuth 2.0 описывает четыре способа получения токена (grant type) и механизм расширений. С тех пор появились новые потоки, а два старых признаны небезопасными.

Поток Для чего Статус
Authorization Code + PKCE веб-приложения с серверной частью, SPA, мобильные и десктопные приложения основной вариант; для публичных клиентов PKCE обязателен, для конфиденциальных рекомендован
Client Credentials сервер к серверу, без пользователя: доступ к своим ресурсам клиента только для конфиденциальных клиентов
Device Authorization телевизоры, приставки, принтеры: нет браузера или неудобно вводить текст пользователь подтверждает доступ на другом устройстве
Refresh Token обновление токена доступа для публичных клиентов с ротацией или привязкой
Implicit токен сразу в redirect URI, для старых SPA не рекомендуется: токен уязвим к утечке и повторному использованию
Resource Owner Password Credentials приложение получает учётные данные пользователя, логин и пароль запрещён: пароль попадает клиенту, поток не рассчитан на двухфакторную аутентификацию

Телевизор с размытым экраном входа и рука со смартфоном перед ним

Мобильному приложению страницу авторизации нужно открывать в системном браузере. Встроенный WebView для запроса авторизации запрещён спецификацией для нативных приложений.

Безопасность OAuth 2.0: что проверить в своей интеграции

Перед запуском интеграции с OAuth пройдитесь по списку:

  • все адреса авторизации и выдачи токенов работают только по HTTPS;
  • redirect URI зарегистрированы полностью, без масок, и на сайте нет открытых редиректов, которые переадресуют на адрес из параметра запроса;
  • токены не попадают в URL и в логи;
  • доступ минимальный: в scope только нужные права, токен ограничен нужным API;
  • на страницах приложения нет XSS-уязвимостей: внедрённый скрипт выполняется с правами пользователя и отправляет запросы от его имени;
  • если SPA обращается к API на другом домене, на сервере API настроены заголовки CORS: браузер проверяет их отдельно от OAuth.

Мы бы не стали использовать в новом проекте ни Implicit, ни Password: для обоих сценариев есть Authorization Code с PKCE.

Разработчик просматривает код на двух мониторах в тёмном кабинете

OAuth 2.1: что изменится

OAuth 2.1 пока не стандарт. На 9 октября 2026 года это черновик draft-ietf-oauth-v2-1-16 от 2 сентября 2026 года, окончательную версию планируют отправить на утверждение в декабре 2026-го. Черновик сводит в один документ OAuth 2.0, PKCE, правила для нативных и браузерных приложений, использование bearer-токенов и поздние рекомендации по безопасности:

  • PKCE входит в Authorization Code по умолчанию, метод plain убран;
  • redirect URI сравниваются точно, посимвольно;
  • Implicit и Password из спецификации исключены;
  • bearer-токены нельзя передавать в строке запроса URL;
  • refresh token публичного клиента привязан к клиенту или одноразовый;
  • при обмене кода на токен параметр redirect_uri больше не передаётся.

Частые вопросы об OAuth 2.0

Чем OAuth 2.0 отличается от OAuth 1.0?

Версии несовместимы, общих деталей реализации у них очень мало. В OAuth 1.0 клиент подтверждает авторизованный запрос подписью (методы HMAC-SHA1, RSA-SHA1 или PLAINTEXT). В OAuth 2.0 используются bearer-токены: кто владеет токеном, тот и может им пользоваться, поэтому передают его только по HTTPS. OAuth 1.0 спецификация второй версии оставляет для уже работающих систем, новые интеграции делают на OAuth 2.0.

Можно ли хранить client_secret в мобильном приложении?

Нет. Мобильное приложение относится к публичным клиентам OAuth: сохранить секрет в тайне оно не может. Такие приложения регистрируют без секрета и защищают обмен кода с помощью PKCE.

Нужен ли PKCE, если у приложения есть client_secret?

Рекомендуется. Для конфиденциальных клиентов, например серверных приложений с секретом, PKCE защищает от подмены и повторного использования кода авторизации и заодно закрывает CSRF, если сервер его проверяет. В черновике OAuth 2.1 он часть Authorization Code для всех клиентов.

Сколько живёт access token?

Единого срока нет, его задаёт сервер авторизации и сообщает в expires_in. Код авторизации, в отличие от него, по рекомендации спецификации живёт не дольше 10 минут.

Оцените статью
bestprogrammer.ru
Добавить комментарий