OpenIG: авторизация доступа через OAuth (на примере Яндекс ID)

Введение

В статье мы настроим авторизацию доступа в приложение через аутентификацию по протоколу OAuth 2.0 на шлюзе с открытым исходным кодом OpenIG. В качестве сервиса аутентификации будем использовать Яндекс ID.

Создание приложения Яндекс ID

Создайте приложение Яндекс ID, как описано по ссылке.

В запрашиваемых правах выберите “Доступ к адресу электронной почты”.

В список “Redirect URI для веб-сервисов” добавьте URI OpenIG: http://openig.example.org:8080/app/callback

Подготовка

Для быстрого развертывания шлюза OpenIG на локальной машине нам понадобится Docker.

Пусть имя хоста для OpenAM будет openig.example.org Перед запуском добавьте имя хоста и IP адрес в файл hosts, например 127.0.0.0.1 openig.example.org

В системах под управлением Windows файл hosts расположен в C:\Windows\System32\drivers\etc\hosts, а в Linux и Mac расположен в /etc/hosts.

Настройка OpenIG

Создайте папку openig-config и в ней еще одну одну папку config. В папке config создайте два файла: config.json и admin.json со следующим содержимым.

config.json :

{
  "heap": [],
  "handler": {
    "type": "Chain",
    "config": {
      "filters": [],
      "handler": {
        "type": "Router",
        "name": "_router",
        "capture": "all"
      }
    }
  }
}

admin.json:

{
    "prefix": "openig",
    "mode": "PRODUCTION"
}

Тестовое приложение

Создайте маршрут к защищаемоу приложению, запросы к которому будут выполняться через OpenIG. В папке config создайте папку маршрутов routes . И добавьте в папку маршрут 01-app.json .

{
  "heap": [],
  "handler": {
    "type": "Chain",
    "config": {
      "filters": [],
      "handler": {
        "name": "EndpointHandler",
        "type": "DispatchHandler",
        "config": {
          "bindings": [
            {
              "handler": "ClientHandler",
              "capture": "all",
              "baseURI": "${system['endpoint.app']}"
            }
          ]
        }
      }
    }
  },
  "condition": "${matches(request.uri.path, '^/app')}"
}

Приложение состоит из двух файлов: index.html и main.js.

index.html

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="icon" href="data:," />
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"
        integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">

</head>
<body>
    <div class="container my-5">
        <div id="alert" class="alert alert-danger" role="alert" style="display: none;">
            
        </div>
        <h1>Test OAuth 2 OpenIG Example</h1>
        <div id="login">
            <div class="col-lg-8 px-0">
                <p class="fs-5">You are not authenticated.<br>Press the Login button to continue.</p>
                <hr class="col-1 my-4">
                <button id="loginButton" type="button" class="btn btn-primary">Login</button>
            </div>
        </div>
        <div id="profile" style="display: none;">
            <div class="col-lg-8 px-0">
                <p class="fs-5">Authenticated with <span id="email">undefined</span></p>
            </div>
        </div>

    </div>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
        crossorigin="anonymous"></script>
    <script src="js/main.js"></script>
</body>
</html> 

main.js

// Usage example
window.onload = function () {
    
    function setError(text) {
        const alert =  document.getElementById('alert');
        alert.textContent = text;
        alert.style.display = '';
    }

    document.getElementById('loginButton')?.addEventListener('click', doLogin);

    async function setUserData(userData) {
        document.getElementById('login').style.display = 'none';
        document.getElementById('email').textContent = userData.email;
        document.getElementById('profile').style.display = '';
    }

    async function getUserData(accessToken) {
        try {
            const response = await fetch("/userinfo", {
                headers: {
                    "Authorization": "Bearer " + accessToken,
                  },
            });
            if(response.ok) {
                const userData = await response.json();
                console.log('User data:', userData);
                setUserData(userData);
            } else if (response.status == 403) {
                setError("got http status 403 Forbidden");
            } else if (response.status == 401) {
                setError("got http status 401 Not Authenticated");
            } else {
                setError("got http status " + response.status + " " + response.statusText);
            }
            
        } catch (error) {
            console.error('get user data failed:', error);
            setError("get user data failed: " + error);
        }
    }

    async function doLogin() {
        try {
            const response = await fetch("/oauth?goto=/app", {
                redirect: 'manual'
            });
            if (response.type === "opaqueredirect") {
                window.sessionStorage.setItem("autoLogin", true);
                window.location.href = response.url;
                return;
            }
            const tokenData = await response.json();
            const accessToken = tokenData.access_token;
            console.log('Access Token:', accessToken);
            getUserData(accessToken)

        } catch (error) {
            console.error('Token exchange failed:', error);
            setError('Token exchange failed:' + error);
        }
    }

    if(window.sessionStorage.getItem("autoLogin")) {
        window.sessionStorage.removeItem("autoLogin");
        doLogin();
    }
}

Логика приложения довольно проста. При нажатии на кнопку Login выполняется запрос к конечной точке получения access_token OpenIG. Конечная точка возвращает access_token либо перенаправляет на аутентификацию в Яндекс ID. После аутентификации возвращается access_token.

После получения access_token приложение обращается к конечной точке OpenIG. access_token передается в заголовке Authorization . OpenIG получает информацию об учетной записи и авторизует запрос. Если авторизация успешна, отображает данные пользователя. В противном случае, приложение отображает ошибку аутентификации или авторизации.

Получение access_token

В папку config/routes Добавьте маршрут 02-oauth.json

02-oauth.json :

{
  "heap": [
    {
      "name": "Yandex",
      "type": "Issuer",
      "config": {
        "authorizeEndpoint": "https://oauth.yandex.ru/authorize",
        "tokenEndpoint": "https://oauth.yandex.ru/token"
      }
    },
    {
      "comment": "To reuse client registrations, configure them in the parent route",
      "name": "OAuth2RelyingParty",
      "type": "ClientRegistration",
      "config": {
        "issuer": "Yandex",
        "clientId": "${system['oauth.client_id']}",
        "clientSecret": "${system['oauth.client_secret']}",
        "scopes": [
          "login:email"
        ]
      }
    }
  ],
  "handler": {
    "type": "Chain",
    "config": {
      "filters": [
        {
          "type": "OAuth2ClientFilter",
          "config": {
            "clientEndpoint": "/oauth",
            "defaultLoginGoto": "/app",
            "requireHttps": false,
            "requireLogin": true,
            "target": "${attributes.access_token}",
            "failureHandler": {
              "type": "StaticResponseHandler",
              "config": {
                "status": 500,
                "reason": "Error",
                "entity": "${attributes.access_token}"
              }
            },
            "registrations": "OAuth2RelyingParty"
          }
        }        
      ],
      "handler": {
        "name": "AccessTokenHandler",
        "type":"StaticResponseHandler",
        "config": {
           "status": 200,
           "headers" : {
            "Content-Type" : ["application/json"]
           },
           "entity": "{ \"access_token\": \"${attributes.access_token.access_token}\" }"
        }
      }
    }
  },
  "condition": "${matches(request.uri.path, '^/oauth')}",
  "baseURI": "http://openig.example.org:8080"
}

Остановимся на конфигурации маршрута подробнее. Для получения access_token используется authorization code flow.

Запрос пользователя на конечную точку /oauth проходит через фильтрOAuth2ClientFilter, который перенаправляет пользователя на сервер Яндекс ID для аутентификации.

Параметры клиента OAuth 2 настраиваются объектами Issuer и ClientRegistration в heap маршрута.

Для ClientRegistration укажите в параметрах clientId и clientSecret значения из приложения, которое вы зарегистрировали в Яндекс ID.

После успешной аутентификации, Яндекс возвращает код для получения access_token в OpenIG.

OAuth2ClientFilter обменивает полученный код на access_token.

AccessTokenHandler возвращает JSON объект с полученным access_token .

Аутентификация и авторизация запроса

Создайте маршрут получения информации о пользователе 03-userinfo.json

{
  "heap": [
    {
      "name": "UnAuthorizedHandler",
      "type": "StaticResponseHandler",
      "config": {
        "status": 403,
        "reason": "Forbidden",
        "headers": {
          "Content-Type": [
            "application/json"
          ]
        },
        "entity": "{\"error\": \"forbidden\" }"
      }
    },
    {
      "name": "UnAuthenticatedHandler",
      "type": "StaticResponseHandler",
      "config": {
        "status": 401,
        "reason": "Unauthenticated",
        "headers": {
          "Content-Type": [
            "application/json"
          ]
        },
        "entity": "{\"error\": \"unauthenticated\" }"
      }
    }
  ],
  "handler": {
    "type": "Chain",
    "config": {
      "filters": [
        {
          "name": "ProtectedResourceFilter",
          "type": "OAuth2ResourceServerFilter",
          "config": {
            "tokenInfoEndpoint": "https://login.yandex.ru/info?format=jwt",
            "accessTokenResolver": {
              "type": "ScriptableAccessTokenResolver",
              "config": {
                "type": "application/x-groovy",
                "file": "ResolveAccessToken.groovy"
              }
            },
            "requireHttps": false,
            "providerHandler": "ClientHandler",
            "scopes": [],
            "cacheExpiration": "2 minutes"
          }
        },
        {
          "name": "AuthenticationFilter",
          "type": "ConditionEnforcementFilter",
          "config": {
            "condition": "${not empty (contexts['oauth2'])}",
            "failureHandler": "UnAuthenticatedHandler"
          }
        },
        {
          "name": "AuthorizationFilter",
          "type": "ConditionEnforcementFilter",
          "config": {
            "condition": "${contains(system['allowedEmails'].split(','), contexts['oauth2'].accessToken.info.email)}",
            "failureHandler": "UnAuthorizedHandler"
          }
        }
      ],
      "handler": {
        "name": "UserInfoHandler",
        "type": "StaticResponseHandler",
        "config": {
          "status": 200,
          "headers": {
            "Content-Type": [
              "application/json"
            ]
          },
          "entity": "{\"email\": \"${contexts['oauth2'].accessToken.info['email']}\", \"balance\": 10000 }"
        }
      }
    }
  },
  "condition": "${matches(request.uri.path, '^/userinfo')}",
  "baseURI": "http://openig.example.org:8080"
}

Запрос проходит через ProtectedResourceFilter, который получает информацию об аутентифицированной учетной записи от сервиса Яндекс ID по access_token переданному в HTTP заголовке Authorization и кеширует ее на срок 2 минуты.

Фильтр использует скрипт на языке Groovy для получения данных пользователя по полученному access_token ResolveAccessToken.groovy. Groovy скрипты создаются в папке openig-config/scripts

ResolveAccessToken.groovy:

import org.forgerock.http.oauth2.AccessTokenInfo
import org.forgerock.json.JsonValue

import org.forgerock.json.jose.builders.JwtBuilderFactory
import org.forgerock.json.jose.jws.SignedJwt

logger.info("getting JWT with user info...")
def httpRequest = new Request()
httpRequest.method = "GET"
httpRequest.uri = config.tokenInfoEndpoint.asString()
httpRequest.headers['Authorization'] = "OAuth " + token
def response = http.send(httpRequest).get(5, java.util.concurrent.TimeUnit.SECONDS)
try {
    if(response.status.code != 200) {
        throw new Exception("error getting user info " + response.status)
    }
    def sjwt = new JwtBuilderFactory().reconstruct(response.entity.string, SignedJwt.class)
    logger.info("sjwt: " + sjwt.getClaimsSet())
    return new AccessTokenInfo(new JsonValue(sjwt.getClaimsSet().getProperties().all), token, new HashSet<>(), sjwt.getClaimsSet().getExpirationTime().getTime() * 1000)
} catch(Exception ex) {
    logger.warn("exception occurred: " + ex + ", response " + response.entity)
    throw ex
} finally {
    response.close()
}

Аутентификацию запроса проверяет фильтр AuthenticationFilter . Если access_token не валиден, то возвращается HTTP статус 401.

Авторизацию запроса проверяет фильтр AuthorizationFilter . Он проверяет email в списке допустимых, указанных в системной опции allowedEmails. Если проверка успешна, пропускает запрос дальше. В противном случае возвращается ошибка авторизации, HTTP статус 403.

UserInfoHandler — конечное приложение, возвращает данные аутентифицированного и авторизованного пользователя.

Если при помощи OpenIG нужно авторизовать несколько приложений схожим образом, вы можете вынести фильтры в объект heap файла config.json

Более подробно про настройку OpenIG вы можете почитать в документации.

Проверка

Скачайте код проекта с сайта GitHub по ссылке https://github.com/OpenIdentityPlatform/openig-oauth2-example.

Откройте файл docker-comose.yaml. Установите в переменной окружения CATALINA_OPTS свойствах сервиса openig -Doauth.client_id и -Doauth.client_secret client_id и client_secret вашего приложения, зарегистрированного в Яндекс ID. В аргументе

-DallowedEmails добавьте email, с которым вы будете аутентифицироваться в Яндекс.

Запустите образы Docker OpenIG и защищаемого приложения командой

$ docker compose up

И дождитесь запуска OpenIG и приложения.