Как выглядит процесс авторизации через OAuth в Command-line interface приложении? В стандартном сценарии провайдер перенаправляет обратно на сайт или в мобильное приложение (в случае с OAuth 2), а как перенаправлять в программу в терминале?

В статье будет рассмотрен процесс OAuth авторизации в CLI приложении на примере HeadHunter.

Исходный код можно найти здесь.

Что такое OAuth?

OAuth 2 — это стандарт авторизации, который дает возможность приложениям, таким как Facebook, GitHub и DigitalOcean, запросить доступ к пользовательским аккаунтам через HTTP. Методика заключается в перенаправлении процесса проверки подлинности на сервис, где зарегистрирован аккаунт пользователя, и предоставлении разрешения приложениям третьих сторон на использование этого аккаунта. OAuth 2 предусматривает механизмы авторизации специально для веб и настольных приложений, а также для устройств мобильных платформ.

Абстрактный процесс авторизации

  1. Приложение запрашивает у пользователя авторизацию на доступ к ресурсам сервиса.

  2. Если пользователь авторизовал запрос, приложение получает разрешение на авторизацию.

  3. Приложение запрашивает токен доступа у сервера авторизации (API), предоставляя аутентификацию своей собственной личности и разрешение на авторизацию.

  4. Если личность приложения аутентифицирована и разрешение на авторизацию действительно, сервер авторизации (API) выдает токен доступа к приложению. Авторизация завершена.

  5. Приложение запрашивает ресурс у сервера ресурсов (API) и предоставляет токен доступа для аутентификации.

  6. Если токен доступа действителен, сервер ресурсов (API) передает ресурс приложению.

Авторизация в CLI

  1. Инициирование авторизационного процесса пользователем.

  2. CLI запускает локально временный сервер.

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

  4. Введение пользователем своих учетных данных на сайте провайдера.

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

  6. Локальный сервер регистрирует полученный запрос, передает код авторизации в CLI приложение и завершает свою работу.

  7. Приложение в командной строке использует полученный код для запроса токена доступа у авторизационного сервиса.

  8. Приложение командной строки получает возможность доступа к данным пользователя и может выполнять действия от его имени.

Регистрация приложения в HeadHunter

На странице https://dev.hh.ru/admin регистрируем новое приложение, в поле Redirect URI вводим https://localhost:1505/oauth/hh и ждем одобрения HH. После одобрения заявки будут доступны Client Id, Client Secret.

Создание приложения

В качестве CLI обертки был выбран Click, Flask будет выступать в роли временного веб-сервера.

Пользователь запускает CLI приложение, хочет авторизоваться. Для этого запускается подпроцесс для веб-сервера. Создается объект Queue и передается в дочерний процесс, в который сервер положит код авторизации.

from multiprocessing import Process, Queue

HH_REDIRECT_HOST = "localhost"
HH_REDIRECT_PORT = 1505
HH_REDIRECT_URI = f"https://{HH_REDIRECT_HOST}:{HH_REDIRECT_PORT}/oauth/hh"

def run_server(queue):
    flask_app.config['queue'] = queue
    flask_app.run(HH_REDIRECT_HOST, HH_REDIRECT_PORT,
                  ssl_context=('cert/cert.pem', 'cert/key.pem'),
                  threaded=False,
                  use_reloader=False)


def authorize(client_id: str) -> str:
    queue = Queue()

    server = Process(target=run_server, args=(queue,))
    click.launch(get_oauth_link(client_id))
    server.start()
    server.join()
    authorization_code = queue.get()
    if authorization_code is None:
        raise RuntimeError("No authorization code")
    click.echo(click.style("Authorization code was gotten", fg="green"))
    return authorization_code

Пользователь перенаправляется на страницу с предложением ввести логин, пароль на сайте HH:

def get_oauth_link(client_id: str) -> str:
    url = "https://hh.ru/oauth/authorize?"
    params = {"client_id": client_id,
              "redirect_uri": HH_REDIRECT_URI,
              "response_type": "code"}
    click.echo(url + urllib.parse.urlencode(params))
    return url + urllib.parse.urlencode(params)

В случает правильного логина и пароля HH перенаправляет пользователя на сайт с похожей ссылкой https://localhost:1505/oauth/hh?code=NU0OIMNIBUYINMPOKM8865LOINJI66U36BNKBP3F55V2UPJBUBL43553V2S9DJ

Веб-сервер, запущенный ранее, видит запрос, читает параметр code, передает его в межпроцессный буфер Queue и отключается.

flask_app = Flask(__name__)

log = logging.getLogger('werkzeug')
log.setLevel(logging.ERROR)


@flask_app.route('/oauth/<provider>', methods=['GET'])
def oauth(provider):
    match provider:
        case 'hh':
            code = request.args.get('code', default=None, type=str)
        case _:
            return jsonify({"status": "ERROR", "message": "Wrong provider"})
    if code is None:
        return jsonify({"status": "ERROR", "message": "No code in redirect URI params"})
    flask_app.config['queue'].put(code)

    return jsonify({"status": "SUCCESSFUL"}) # никогда не вернется

@flask_app.teardown_request
def shutdown_process(response):
    os.kill(os.getpid(), signal.SIGINT)

CLI приложение в это время ждет, когда веб-сервер отключится. После отключения получает код авторизации и делает запрос на получение кода доступа к данным (по документации это должен делать удаленный сервер, передача Client Secret на пользователькое устройство не предусмотрено, но для упрощения будет создан толстый клиент без бэкэнда).

def json_api_factory(response_model: type):
    def json_api(func):
        def json_api_wrapper(*args, **kwargs):
            res = func(*args, **kwargs)
            if res.status_code == 200:
                return response_model(**res.json())
            else:
                click.echo(click.style(f"Request failed {res.status_code}: {res.json()}", fg='red'))

        return json_api_wrapper

    return json_api

class OAuthTokenResponse(BaseModel):
    access_token: str
    token_type: str
    refresh_token: str
    expires_in: int

class OAuth:
    def __init__(self, client_id: str, client_secret: str, redirect_uri: str):
        self.client_id = client_id
        self.client_secret = client_secret
        self.redirect_uri = redirect_uri

    @json_api_factory(OAuthTokenResponse)
    def token(self, authorization_code: str) -> OAuthTokenResponse:
        url = "https://hh.ru/oauth/token"
        body = {
            "grant_type": "authorization_code",
            "client_id": self.client_id,
            "client_secret": self.client_secret,
            "redirect_uri": self.redirect_uri,
            "code": authorization_code
        }
        headers = {
            "Content-Type": "application/x-www-form-urlencoded"
        }
        res = requests.post(url, data=body, headers=headers)
        return res

Полученный access_token добавляется в заголовки запросов:

Authorization: Bearer ACCESS_TOKEN

Вывод

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

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


  1. zeldigas
    14.01.2024 21:19
    +2

    Для cli приложений есть специальный Flow - device code.
    Подробности - https://oauth.net/2/grant-types/device-code/


  1. powerman
    14.01.2024 21:19

    А не проще для CLI приложений использовать т.н. "application password"? По сути это аналог токена без экспайра, который юзер создаёт на том же сайте, а потом прописывает в конфиг CLI приложения вместо своего настоящего пароля.

    Ведь в этом случае CLI может ничего про OAuth не знать и не иметь никаких проблем из-за возможных конфликтов портов, потенциальной уязвимости через перехват кода на этом порту сторонним приложением, сложностей из-за встроенного веб-сервера и необходимости обновлять токен автоматически либо просить юзера снова логиниться через браузер…


    1. ValeryV Автор
      14.01.2024 21:19
      +1

      Tip: App passwords aren”t recommended and are unnecessary in most cases. To help keep your account secure, use “Sign in with Google” to connect apps to your Google Account.

      Гугл советует пользоваться OAuth, нежели app password. Перехват возможен, т.к. не используется https, но, согласно документации, передаваемый код имеет короткий срок жизни.


      1. powerman
        14.01.2024 21:19
        +2

        Советует - там, где это возможно и работает. А для остальных ситуаций поддерживает application password. Как по мне - CLI это как раз та ситуация, когда использование OAuth не совсем уместно. В частности, многие CLI утилиты должны быть работоспособны на сервере, где браузера нет. А даже если они используются на десктопе, они зачастую используются из скриптов или других приложений, в рамках работы которых запускать браузер и требовать наличия юзера за компом может быть совершенно неуместно. Добавим к этим недостаткам ещё и сложности с реализацией описанного в статье, и вопрос "нафига?" становится крайне уместным.

        Насколько я помню доку по OAuth, в ней нет такого понятия как application password. Т.е. по факту это отдельная фича (не связанная с OAuth), которую разные сервисы могут реализовывать или нет, и делать это любым способом. Соответственно, такие application password могут не поддерживать ограничение прав доступа а-ля OAuth scope. Ну и как сервис реализует видимость для юзера созданных им application passwords и возможность их удалять - тоже зависит от конкретного сервиса. Насколько я понимаю - на этих пунктах недостатки этого подхода заканчиваются. В остальном - подход достаточно общепринятый и используется многими сервисами (напр. на гитхабе и на Digital Ocean эта фича называется Personal Access Tokens).

        Иными словами данный совет гугла - это его личное предпочтение. Мотивация его неясна, причин не использовать application password там, где клиентам это удобнее - нет.


        1. inkelyad
          14.01.2024 21:19

          Теоретически вообще можно использовать mTLS, когда мы получаем (правильно, через Certificate Request, или неправильно - когда все просто на сервере генерируется), сертификат, подписанный собственным Certificate Authority сервера и сервер прямо на уровне транспорта предьявляемый клиентский сертификат проверяет. Но почему-то этот способ очень непопулярен.


          1. powerman
            14.01.2024 21:19

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


            1. inkelyad
              14.01.2024 21:19

              Тут про CLI приложение речь идет. Скачать файлик сертификата и отдать его приложению - в общем, не сильно сложнее, чем application password сконфигурировать.

              А так - смотри Passkeys. Оно именно это (выдача, установка в бразеры итд) и есть. Только почему-то новые интерфейсы написали вместо использования/додельки существующих.


              1. powerman
                14.01.2024 21:19

                Passkeys/WebAuthn вообще и не про сертификаты и не про CLI. Это про то, чтобы юзер пароли не запоминал. Совсем другая задача и другое решение. Как это может помочь организовать доступ CLI к API сайта более простым способом чем application passwords - мне лично пока неясно. Как минимум это создаст зависимость CLI от внешнего аутентификатора (хардварного или программного), а они пока не особо популярны и распространены.


                1. inkelyad
                  14.01.2024 21:19

                  Passkeys/WebAuthn вообще и не про сертификаты

                  Я про то, что устроено оно довольно похоже - ассиметричная криптография, приватная часть хранится локально и служит для входа на сервер. Того же самого(нигде не видел объяснений, чего именно мешало) можно было добиться, доработав интерфейсы работы с сертификатами TLS в браузерах. То самое удобство установки в браузерах итд.

                  CLI тут действительно, не причем. Для CLI более-менее достаточно пункта "скачать сертификат доступа к сервису" где-нибудь в меню сервиса.


                  1. inkelyad
                    14.01.2024 21:19

                    И, кстати, а как будет выглядеть вход CLI приложения в сервис, в который в браузере - чисто по Passkeys заходят? Да еще с соседнего смартфона. Этот вариант - довольно усердно рекламируют.


    1. ValeryV Автор
      14.01.2024 21:19

      Добавил https, теперь без перехвата)


      1. powerman
        14.01.2024 21:19

        Ну, так лучше, конечно, но перехват ещё можно организовать тупо прибив этот CLI после запуска браузера и подняв на том же порту свой веб-сервер.


  1. ovsds
    14.01.2024 21:19
    +2

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

    1. CLI инициирует авторизацию и встаёт в лонгполлинг на внешний сервер приложения с таймаутом с некоторым генеренным на стороне клиента идентификационным токеном

    2. В браузере открывается ОАуф сессия, которая редиректит в конце на внешний сервер приложения, передавая наш идентификационный токен

    3. Внешний сервер отвечает на лонгполл, если находится с нужным id токеном, передавая данные авторизации oauth

    В идеале, чтоб ещё исключить man-in-the-middle, пользователь на стороне нашего внешнего сервера на форме должен ввести какой-то код, который ему сгенерит CLI в процессе, тот же идентификационный токен и только после этого наш сервер отвечает на лонгполл. Это стоит делать и в вашей схеме, если вы не хотите, что б злоумышленник попытался прикинуться вами и украсть данные пользователя от имени вашего приложения, точно так же подняв локальный сервер.

    Да, стоит не забывать, что если возникает несколько лонгполлов на одном ID токене, то лучше сбросить все.


    1. ovsds
      14.01.2024 21:19
      +1

      Немного подумав дополню сам себя:

      • Лонгполлинг не обязательно использовать, можно обойтись запрос-ответами.

      • Девайс код не поможет, если сервер поднимать на локолхосте

      • Весь этот усложненный путь стоит использовать ТОЛЬКО, если OAuth-провайдер не реализовал уже за вас расширение device-code, тогда нужно использовать его (ссылки в других ветках комментариев).


  1. zeldigas
    14.01.2024 21:19
    +2

    Для cli приложений есть специальный Flow - device code.
    Подробности - https://oauth.net/2/grant-types/device-code/


    1. Vermut666
      14.01.2024 21:19

      точно. такой в AWS CLI используется для аутентификации через SSO.