Введение
В статье мы настроим авторизацию доступа в приложение через аутентификацию по протоколу 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 и приложения.
-
Откройте в браузере ссылку http://openig.example.org:8080/app.
-
Нажмите кнопку
Login. -
Браузер перенаправит на сервер Яндекса для аутентификации.
-
После успешной аутентификации OpenIG перенаправит браузер обратно в приложение.
-
Если авторизация прошла успешно и
access_tokenвалиден, приложение вернет HTTP статус 200. В теле ответа будет email аутентифицированного пользователя, а так же его баланс. Приложение покажет данные аутентифицированного и авторизованного пользователя. -
Если срок действия
access_tokenистек илиaccess_tokenбыл отозван, OpenIG вернет ответ со статусом 401. Приложение покажет сообщение об ошибке. -
Если авторизация неуспешна, то есть email пользователя отсутствует в списке разрешенных, OpenIG вернет ответ с HTTP статусом 403 и приложение покажет сообщение об ошибке.
ссылка на оригинал статьи https://habr.com/ru/articles/900576/
Добавить комментарий