В статье мы настроим авторизацию доступа в приложение через аутентификацию по протоколу OAuth 2.0 на шлюзе с открытым исходным кодом OpenIG. В качестве сервиса аутентификации будем использовать Яндекс 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-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 получает информацию об учетной записи и авторизует запрос. Если авторизация успешна, отображает данные пользователя. В противном случае, приложение отображает ошибку аутентификации или авторизации.
В папку 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 и приложения.
Login
.access_token
валиден, приложение вернет HTTP статус 200. В теле ответа будет email аутентифицированного пользователя, а так же его баланс. Приложение покажет данные аутентифицированного и авторизованного пользователя.access_token
истек или access_token
был отозван, OpenIG вернет ответ со статусом 401. Приложение покажет сообщение об ошибке.