Давным-давно… с чего все начиналось
Несколько лет назад, когда внутренних приложений стало слишком много для ручного управления, мы написали приложение для контроля доступов внутри компании. Это было простое Rails-приложение, которое подключалось к базе данных с информацией о сотрудниках, где настраивался доступ к различному функционалу.
Тогда же мы подняли первое SSO, которое основывалось на проверке токенов со стороны клиента и сервера авторизации, токен передавался в шифрованном виде с несколькими параметрами и сверялся на сервере авторизации. Это был не самый удобный вариант так, как на каждом внутреннем приложении нужно было описывать немалый слой логики, а базы сотрудников и вовсе синхронизировались с сервером авторизации.
Спустя некоторое время мы решили упростить задачу централизованной авторизации. SSO перевели на балансер. С помощью OpenResty на Lua добавили шаблон, который проверял токены, знал в какое приложение идет запрос и мог проверить, есть ли туда доступ. Такой подход сильно упростил задачу контроля доступов внутренних приложений — в коде каждого приложения уже не нужно было описывать дополнительную логику. В итоге мы закрыли трафик внешне, а само приложение ничего не знало об авторизации.
Однако одна из проблем осталась нерешенной. Как быть с приложениями, которым нужна информация о сотрудниках? Можно было написать API для сервиса авторизации, но тогда пришлось бы добавлять дополнительную логику для каждого такого приложения. К тому же мы хотели избавиться от зависимости от одного нашего самописного приложения, ориентированного в дальнейшем на перевод в OpenSource, от нашего внутреннего сервера авторизации. О нем мы расскажем как-нибудь в другой раз. Решением обеих проблем стал OAuth.
10 О кешировании данных
Основной проблемой при работе авторизации является точка в которой необходимо кешировать результаты, что бы работало быстрее все, да еще желательно так, что бы нагрузка на ЦПУ не была высокой. Вы можете поместить все ваши данные в память и спокойно их читать, но это дорогое решение и не все готовы будут себе его позволить, особенно если требуется всего лишь пара ТБ ОЗУ.
Правильным решением было бы найти такие места, которые бы позволяли с минимальными затратами по памяти давать максимум быстродействия.
Предлагаю решить такую задачу на примере ролей пользователя и некоего микросервиса, которому нужно проверять наличие роли у пользователя. Разумеется в данном случае можно сделать карту (Пользователь, Роль) -> Boolean. Проблема в том, что все равно придется на каждую пару делать запрос на удаленный сервис.
Даже если вам данные будут предоставлять за 0,1 мс, то ваш код будет работать все равно медленно. Очевидным решением будет кешировать сразу роли пользователя! В итоге у нас будет кеш Пользователь -> Роль[]. При таком подходе на какое-то время микросервис сможет обрабатывать запросы от пользователя без необходимости нагружать другие микросервисы.
Разумеется читатель спросит, а что если ролей у пользователя десятки тысяч? Ваш микросервис всегда работает с ограниченным количеством ролей, которые он проверяет, соответственно вы всегда можете либо захардкодить список, либо найти все аннотации, собрать все используемые роли и фильтровать только их.
Полагаю, что ход мыслей, которому стоит следовать, стал понятен.
5 Роль
Множество разрешений, по сути под словом роль всегда подразумевается множество разрешений.
Следует отметить, что в системе может не быть ролей и напрямую задаваться набор разрешений для каждого пользователя в отдельности. Так же в системе могут отсутствовать разрешения (речь про хранение их в виде записей БД), но быть роли, при этом такие роли будут называться статичными, а если в системе существуют разрешения, тогда роли называются динамическими, так как можно в любой момент поменять, создать или удалить любую роль так, что система все равно продолжит функционировать.
Ролевые модели позволяют как выполнять вертикальное, так и горизонтальное разграничение доступа (описание ниже). Из типа разграничения доступа следует, что сами роли делятся на типы:
Но отсюда следует вопрос, если у пользователя есть глобальная роль и локальная, то как определить его эффективные разрешения? Поэтому авторизация должна быть в виде одной из форм:
Подробное описание использования типа ролей и формы авторизации ниже.
Роль имеет следующие связи:
Следует отметить, что реализация статичных ролей требует меньше вычислительных ресурсов (при определении эффективных разрешений пользователя будет на один джойн меньше), но вносить изменения в такую систему можно только релизным циклом, однако при большом числе ролей гораздо выгоднее использовать разрешения, так как чаще всего микросервису нужно строго ограниченное их число, а ролей с разрешением может быть бесконечное число. Для больших проектов, стоит задуматься о том, что бы работать только с динамическими ролями.
9 Конъюнктивная/дизъюнктивная форма авторизации
Из-за того, что у теперь у нас существуют два варианта наборов разрешений пользователя, то можно выделить два варианта, как объединить все вместе:
Одним из важных преимуществ конъюнктивной формы является жесткий контроль за всеми операциями, которые может выполнять пользователь, а именно — без глобального разрешения пользователь не сможет получить доступ в следствие ошибки на месте. Так же реализация в виде программного продукта горазо проще, так как достаточно сделать последовательность проверок, каждая из которых либо успешна, либо бросает исключение.
Дизъюнктивная форма позволяет гораздо проще делать супер-админов и контролировать их численность, однако на уровне отдельных сервисов потребует написания своих алгортмов авторизации, которые в начале получит все разрешения пользователя, а уже потом либо успешно пройдет, либо бросит исключение.
Рекомендуется всегда использовать только конъюнктивную форму авторизации в виду ее большей гибкости и возможности снизить вероятность появления инцидентов по утечке данных. Так же именно конъюнктивная форма авторизации имеет большую устойчивость к ДДОС атакам в виду использования меньшего объема ресурсов.
Ее проще реализовать и использовать, отлаживать. Разумеется можно и дизъюнктивную форму натравить на анализ аннотаций метода и поиск соответствующих сервисов, но запросы вы отправлять будете скорее всего синхронно, один за другим, если же будете делать асинхронные вызовы, то много ресурсов будет уходить в пустоту, оно вам надо?
2 Логистика
Огромная логистическая компания требует разграничить доступы для супервизоров и продавцов да еще с учетом региона. Работа такова, что продавец может выставить заявку на получение товара, а супервизор уже решает кому и сколько достанется. Соответственно продавец может видеть свой магазин и все товары в нем, а так же созданные им заявки, статус их обработки.
Для реализации такого функционала нам потребуется реестр регионов и магазинов в качестве отдельного микросервиса, назовем его С1. Заявки и историю будем хранить на С2. Авторизация — А.Далее при обращении продавца для получения списка его магазинов (а у него может быть их несколько)
, С1 вернет только те, в которых у него есть меппинг (Пользователь, Магазин), так как ни в какие регионы он не добавлен и для продавца регионы всегда пустое множество. Разумеется при условии, что у пользователя есть разрешение просматривать список магазинов — посредством микросервиса А.
Работа супервизора будет выглядеть немного иначе, вместо регистрации супервизора на каждый магазин региона, мы сделаем меппинги (Пользователь, Регион) и (Регион, Магазин) в этом случае для супервизора у нас всегда будет список актуальных магазинов с которыми он работает.
4 Команда и ее проекты
Если у вас большая команда, и много проектов, и такая команда у вас не одна, то координировать работу их будет той еще задачей. Особенно если таких команд у вас тысячи. Каждому разработчику вы доступы не пропишите, а если у вас человек еще и в нескольких командах работает, то разграничивать доступ станет еще сложнее.
Такую проблему проще всего решить при помощи групп.Группа будет объединять все необходимые проекты в одно целое. При добавлении участника в команду, добавляем меппинг (Пользователь, Группа, Роль).
Допустим, что Вася — разработчик и ему нужно вызвать метод develop на микросервисе для выполнения своих обязанностей. Этот метод потребует у Васи роли в проекте — Разработчик.Как мы уже договаривались — регистрация пользователя в каждый проект для нас недопустима.
Поэтому микросервис проектов обратится в микросервис групп, что бы получить список групп, в которых Вася — Разработчик. Получив список этих групп можно легко проверить уже в БД микросервиса проектов — есть ли у проекта какая-нибудь из полученных групп и на основании этого предоставлять или запрещать ему доступ.
Create a new spring boot app
Back to the Spring Initializr one more time. Create a new project with the following settings:
- Change project type from Maven to Gradle.
- Change the Group to com.okta.spring.
- Change the Artifact to OktaOAuthClient.
- Add three dependencies: Web, Thymeleaf, Okta.
- Click Generate Project.
Copy the project and unpack it somewhere.
In the build.gradle file, add the following dependency:
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.0.4.RELEASE'
Also while you’re there, notice the dependency com.okta.spring:okta-spring-boot-starter:1.1.0. This is the Okta Spring Boot Starter. It’s a handy project that makes integrating Okta with Spring Boot nice and easy. For more info, take a look at the project’s GitHub.
Change the src/main/resources/application.properties to application.yml and add the following:
База данных
Для этого урока мы будем использовать базу данных H2 . Здесь вы можете найти справочную схему SQL OAuth2, необходимую для Spring Security.
Примечание. Поскольку в этом руководстве используется JWT
не все таблицы являются обязательными.
А затем добавьте следующую запись
client_secret
выше client_secret
был создан с использованием bcrypt .
Префикс {bcrypt}
является обязательным, потому что мы будем использовать новую функцию DelegatingPasswordEncoder в Spring Security 5.x.
Говоря о существующих решениях
В рамках нашей организации в качестве первого OIDC-сервера мы собрали свою реализацию, которая дополнялась по мере необходимости. После подробного рассмотрения других готовых решений, можно сказать, что это спорный момент. В пользу решения реализации своего сервера послужили опасения со стороны провайдеров в отсутствии необходимого функционала, а также наличие старой системы в которой присутствовали разные кастомные авторизации для некоторых сервисов и хранилось уже довольно много данных о сотрудниках.
Однако в готовых реализациях, присутствуют удобства для интеграции. Например, в Keycloak своя система менеджмента пользователей и данные хранятся прямо в ней, а перегнать своих пользователей туда не составит большого труда. Для этого в Keycloak есть API, которое позволит в полной мере осуществить все необходимые действия по переносу.
Еще один пример сертифицированной, интересной, на мой взгляд, реализации — Ory Hydra. Интересна она тем, что состоит из разных компонентов. Для интеграции вам понадобится связать свой сервис менеджмента пользователей с их сервисом авторизации и расширять по мере необходимости.
Keycloak и Ory Hydra — не единственные готовые решения. Лучше всего подбирать сертифицированную OpenID Foundation реализацию. Обычно у таких решений есть значок OpenID Certification.
Также не забывайте о существующих платных провайдерах, если вы не хотите держать свой сервер OIDC. На сегодняшний день хороших вариантов много.
Интеграция клиента с сервером
API
Начнем с API (api/messages.ts):
// адрес сервера
const SERVER_URI = process.env.REACT_APP_SERVER_URI
// сервис для получения открытого сообщения
export async function getPublicMessage() {
let data = { message: '' }
try {
const response = await fetch(`${SERVER_URI}/messages/public`)
if (!response.ok) throw response
data = await response.json()
} catch (e) {
throw e
} finally {
return data.message
}
}
// сервис для получения защищенного сообщения
// функция принимает `access_token/токен доступа`
export async function getProtectedMessage(token: string) {
let data = { message: '' }
try {
const response = await fetch(`${SERVER_URI}/messages/protected`, {
headers: {
// добавляем заголовок авторизации с токеном
Authorization: `Bearer ${token}`
}
})
if (!response.ok) throw response
data = await response.json()
} catch (e) {
throw e
} finally {
return data.message
}
}
Страница MessagePage (pages/MessagePage/MessagePage.tsx).
Импортируем хуки, компонент, провайдер, сервисы и стили:
import { useAuth0 } from '@auth0/auth0-react'
import { getProtectedMessage, getPublicMessage } from 'api/messages'
import { Boundary } from 'components/Boundary/Boundary'
import { useAppSetter } from 'providers/AppProvider'
import { useState } from 'react'
import './message.scss'
Получаем сеттеры, определяем состояние для сообщения и его типа:
export const MessagePage = () => {
const { setLoading, setError } = useAppSetter()
const [message, setMessage] = useState('')
const [type, setType] = useState('')
// TODO
}
Для генерации токена доступа (access_token) предназначен метод getAccessTokenSilently, возвращаемый хуком useAuth0:
const { getAccessTokenSilently } = useAuth0()
Определяем функцию для запроса открытого сообщения:
function onGetPublicMessage() {
setLoading(true)
getPublicMessage()
.then(setMessage)
.catch(setError)
.finally(() => {
setType('public')
setLoading(false)
})
}
Определяем функцию для получения защищенного сообщения:
function onGetProtectedMessage() {
setLoading(true)
// генерируем токен и передаем его сервису `getProtectedMessage`
getAccessTokenSilently()
.then(getProtectedMessage)
.then(setMessage)
.catch(setError)
.finally(() => {
setType('protected')
setLoading(false)
})
}
Наконец, возвращаем разметку:
return (
<Boundary>
<h1>Message Page</h1>
<div className='message'>
<button onClick={onGetPublicMessage}>Get Public Message</button>
<button onClick={onGetProtectedMessage}>Get Protected Message</button>
{message && <h2 className={type}>{message}</h2>}
</div>
</Boundary>
)
Сервер
Переходим в директорию server и устанавливаем зависимости:
# зависимости для продакшна
yarn add express helmet cors dotenv express-jwt jwks-rsa
# зависимости для разработки
yarn add -D nodemon
О том, что такое JWKS и для чего он используется, можно почитать здесь.
Пример интеграции jwks-rsa с express-jwt можно найти здесь.
Структура сервера:
- routes
- api.routes.js
- messages.routes.js
- utils
- checkJwt.js
- .env
- index.js
- ...
Здесь нас интересуют 2 файла: messages.routes.js и checkJwt.js.
messages.routes.js:
import { Router } from 'express'
import { checkJwt } from '../utils/checkJwt.js'
const router = Router()
router.get('/public', (req, res) => {
res.status(200).json({ message: 'Public message' })
})
router.get('/protected', checkJwt, (req, res) => {
res.status(200).json({ message: 'Protected message' })
})
export default router
При запросе к api/messages/public возвращается сообщение Public message. При запросе к api/messages/protected выполняется проверка JWT. Данный маршрут (роут) является защищенным. Когда проверка прошла успешно, возвращается сообщение Protected message. В противном случае, утилита возвращает ошибку.
Рассмотрим этого посредника (utils/checkJwt.js).
Импортируем утилиты:
import jwt from 'express-jwt'
import jwksRsa from 'jwks-rsa'
import dotenv from 'dotenv'
Получаем доступ к переменным среды окружения, хранящимся в файле .env, и извлекаем их значения:
dotenv.config()
const domain = process.env.AUTH0_DOMAIN
const audience = process.env.AUTH0_AUDIENCE
audience — простыми словами, это аудитория токена, т.е. те, для кого предназначен токен.
Определяем утилиту:
Клиент
Переходим в директорию client и устанавливаем дополнительные зависимости:
cd client
# зависимости для продакшна
yarn add @auth0/auth0-react react-router-dom react-loader-spinner
# зависимость для разработки
yarn add -D sass
Структура директории src:
- api
- messages.ts
- components
- AuthButton
- LoginButton
- LoginButton.tsx
- LogoutButton
- LogoutButton.tsx
- AuthButton.tsx
- Boundary
- Error
- error.scss
- Error.tsx
- Spinner
- Spinner.tsx
- Boundary.tsx
- Navbar
- Navbar.tsx
- pages
- AboutPage
- AboutPage.tsx
- HomePage
- HomePage.tsx
- MessagePage
- message.scss
- MessagePage.tsx
- ProfilePage
- profile.scss
- ProfilePage.tsx
- providers
- AppProvider.tsx
- Auth0ProviderWithNavigate.tsx
- router
- AppRoutes.tsx
- AppLinks.tsx
- styles
- _mixins.scss
- _variables.scss
- types
- index.d.ts
- utils
- createStore.tsx
- App.scss
- App.tsx
- index.tsx
...
Логика работы приложения:
Дальше я буду рассказывать только о том, что касается непосредственно Auth0.
Интеграция приложения с Auth0
Для интеграции приложения с Auth0 используется провайдер Auth0Provider.
Для того, чтобы иметь возможность переправлять пользователя на кастомную страницу после входа в систему, дефолтный провайдер необходимо апгрейдить следующим образом (providers/Auth0ProviderWithNavigate):
// импортируем дефолтный провайдер
import { Auth0Provider } from '@auth0/auth0-react'
// хук для выполнения программной навигации
import { useNavigate } from 'react-router-dom'
import { Children } from 'types'
const domain = process.env.REACT_APP_AUTH0_DOMAIN as string
const clientId = process.env.REACT_APP_AUTH0_CLIENT_ID as string
const audience = process.env.REACT_APP_AUTH0_AUDIENCE as string
const Auth0ProviderWithNavigate = ({ children }: Children) => {
const navigate = useNavigate()
// функция, вызываемая после авторизации
const onRedirectCallback = (appState: { returnTo?: string }) => {
// путь для перенаправления указывается в свойстве `returnTo`
// по умолчанию пользователь возвращается на текущую страницу
navigate(appState?.returnTo || window.location.pathname)
}
return (
<Auth0Provider
domain={domain}
clientId={clientId}
// данная настройка нужна для взаимодействия с сервером
audience={audience}
redirectUri={window.location.origin}
onRedirectCallback={onRedirectCallback}
>
{children}
</Auth0Provider>
)
}
export default Auth0ProviderWithNavigate
С сигнатурой провайдера можно ознакомиться здесь.
Оборачиваем компоненты приложения в провайдер (index.tsx):
import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import Auth0ProviderWithNavigate from 'providers/Auth0ProviderWithNavigate'
import { AppProvider } from 'providers/AppProvider'
import App from './App'
ReactDOM.render(
<React.StrictMode>
{/* провайдер маршрутизации */}
<BrowserRouter>
{/* провайдер авторизации */}
<Auth0ProviderWithNavigate>
{/* провайдер состояния */}
<AppProvider>
<App />
</AppProvider>
</Auth0ProviderWithNavigate>
</BrowserRouter>
</React.StrictMode>,
document.getElementById('root')
)
Вход и выход из системы
Для входа в систему используется метод loginWithRedirect, а для выхода — метод logout. Оба метода возвращаются хуком useAuth0. useAuth0 также возвращает логическое значение isAuthenticated (и много чего еще) — статус авторизации, который можно использовать для условного рендеринга кнопок.
Вот как реализована кнопка для аутентификации (components/AuthButton/AuthButton.tsx):
// импортируем хук
import { useAuth0 } from '@auth0/auth0-react'
import { LoginButton } from './LoginButton/LoginButton'
import { LogoutButton } from './LogoutButton/LogoutButton'
export const AuthButton = () => {
// получаем статус авторизации
const { isAuthenticated } = useAuth0()
return isAuthenticated ? <LogoutButton /> : <LoginButton />
}
Кнопка для входа в систему (components/AuthButton/LoginButton/LoginButton.tsx):
// импортируем хук
import { useAuth0 } from '@auth0/auth0-react'
export const LoginButton = () => {
// получаем метод для входа в систему
const { loginWithRedirect } = useAuth0()
return (
<button className='auth login' onClick={loginWithRedirect}>
Log In
</button>
)
}
Кнопка для выхода из системы (components/AuthButton/LogoutButton/LogoutButton.tsx):
// импортируем хук
import { useAuth0 } from '@auth0/auth0-react'
export const LogoutButton = () => {
// получаем метод для выхода из системы
const { logout } = useAuth0()
return (
<button
className='auth logout'
// после выхода из системы, пользователь перенаправляется на главную страницу
onClick={() => logout({ returnTo: window.location.origin })}
>
Log Out
</button>
)
}
С сигнатурой хука можно ознакомиться здесь.
Состояние авторизации
Состояние авторизации пользователя сохраняется на протяжении времени жизни id_token/токена идентификации. Время жизни токена устанавливается на вкладке Settings приложения в поле ID Token Expiration раздела ID Token и по умолчанию составляет 36 000 секунд или 10 часов:
Токен записывается в куки, которые можно найти в разделе Storage/Cookies вкладки Application инструментов разработчика в браузере:
Это означает, что статус авторизации пользователя сохраняется при перезагрузке страницы, закрытии/открытии вкладки браузера и т.д.
При выходе из системы куки вместе с id_token удаляется.
Создание защищенной страницы
Код авторизации
Параметры запроса:
Поле | Значение | Описание |
---|---|---|
client_id | client_id | Обязательный. Идентификатор клиента (приложения). |
redirect_uri | redirect_uri | Обязательный. URI, на который сервер авторизации перенаправит агента пользователя (браузер) и код авторизации. |
response_type | code | Обязательный. Указывает на то, что приложение запрашивает доступ с помощью кода авторизации. |
scope | scope | Рекомендуемый. Список областей, разделенных пробелами, которые определяют ресурсы, к которым ваше приложение может получить доступ от имени пользователя. |
access_type | access_type | Рекомендуемый. Указывает, может ли ваше приложение обновлять маркеры доступа, когда пользователь отсутствует в браузере. Допустимые значения параметров: online (по умолчанию) и offline. |
state | state | Рекомендуемый. Набор случайных символов которые будут возвращены сервером клиенту (используется для защиты от повторных запросов). |
Пример зпроса:
Если в ходе аутентификации не возникло ошибок, то сервер авторизации перенаправит пользователя по ссылке, указанной в redirect_uri, а также вернёт два обязательных параметра:
- code – Код авторизации;
- state – Значение параметра
state
, которое было получено в запросе на аутентификацию;
Клиент должен провести сравнение отправленного и полученного параметра state.
Ответ с кодом авторизации:
Если в ходе аутентификации возникла ошибка, то сервер авторизации перенаправит пользователя по ссылке, указанной в redirect_uri с информацией об ошибке:
Для обмена кода авторизации на маркер доступа Клиент должен сформировать запрос методом POST.
Параметры запроса:
В ответ на запрос сервер авторизации, вернет объект JSON, который содержит маркер краткосрочного доступа и маркер обновления.
Ответ содержит следующие поля:
Поле | Тип | Описание |
---|---|---|
access_token | STRING | Маркер краткосрочного доступа (сроком действия 1 час). |
expires_in | INTEGER | Оставшееся время жизни маркера доступа в секундах. |
token_type | STRING | Тип возвращаемого маркера. Значение всегда будет Bearer. |
session | STRING | Идентификатор сессии пользователя. |
refresh_token | STRING | * Маркер который вы можете использовать для получения нового маркер доступа. |
id_token | STRING | * Маркер пользователя. |
- Обратите внимание, что маркер обновления возвращается только в том случае, если ваше приложение в первоначальном запросе к серверу авторизации, установило в
access_type
значение: offline. - Обратите внимание, что маркер пользователя возвращается только в том случае, если ваше приложение в первоначальном запросе к серверу авторизации, установило в
scope
одно из значений: openid, profile или email.
Пример зпроса:
Конфигурация сервера ресурсов
Для декодирования токена JWT необходимо будет использовать public key из самозаверяющего сертификата, используемого на сервере авторизации для подписи токена, для этого сначала создадим класс @ConfigurationProperties для привязки свойств конфигурации.
Используйте следующую команду для экспорта public key из сгенерированного JKS:
Пример ответа выглядит так:
Скопируйте его в файл public.txt и поместите его в /src/main/resources а затем настройте ваш application.yml указывая на этот файл:
Теперь давайте добавим конфигурацию Spring для сервера ресурсов.
Важной частью этой конфигурации являются три @Bean : JwtAccessTokenConverter , TokenStore и DefaultTokenServices :
JwtAccessTokenConverter
используетpublic key
JwtAccessTokenConverter
.JwtTokenStore
используетJwtAccessTokenConverter
для чтения токенов.DefaultTokenServices
используетJwtTokenStore
для сохранения токенов.
Неявный
Неявный тип разрешения на авторизацию используется мобильными и веб-приложениями (приложениями, которые работают в веб-браузере – JavaScript), где конфиденциальность секрета клиента не может быть гарантирована. Неявный тип разрешения также основан на перенаправлении пользовательского агента, при этом маркер доступа передаётся пользовательскому агенту для дальнейшей передачи приложению. Это, в свою очередь, делает маркер доступным пользователю и другим приложениям на устройстве пользователя. Также при этом типе разрешения на авторизацию не осуществляется аутентификация подлинности приложения, а сам процесс полагается на URI перенаправления (зарегистрированном ранее в сервере авторизации).
Неявный тип разрешения на авторизацию не поддерживает маркеры обновления (
refresh_token
) и маркера пользователя (id_token
).
Параметры запроса:
Поле | Значение | Описание |
---|---|---|
client_id | client_id | Обязательный. Идентификатор клиента (приложения). |
redirect_uri | redirect_uri | Обязательный. URI, на который сервер авторизации перенаправит агента пользователя (браузер) и маркер доступа. |
response_type | token | Обязательный. Приложения JavaScript должны установить значение параметра в token. Это значение указывает серверу авторизации возвращать маркер доступа в виде пары name = value в идентификаторе фрагмента URI (#), на который перенаправляется пользователь после завершения процесса авторизации. |
scope | scope | Рекомендуемый. Список областей, разделенных пробелами, которые определяют ресурсы, к которым ваше приложение может получить доступ от имени пользователя. |
state | state | Рекомендуемый. Набор случайных символов которые будут возвращены сервером клиенту (используется для защиты от повторных запросов). |
Пример зпроса:
Маркер доступа или сообщение об ошибке возвращаются во фрагменте хэша URI перенаправления, как показано ниже:
Ответ с маркером доступа:
Ответ с ошибкой:
Подготовка и настройка проекта
Создаем директорию, переходим в нее и создаем клиента — шаблон React/TypeScript-приложения с помощью Create React App:
mkdir react-auth0
cd react-auth0 # cd !$
yarn create react-app client --template typescript
# or
npx create-react-app ...
Создаем директорию для сервера, переходим в нее и инициализируем Node.js-приложение:
mkdir server
cd server
yarn init -yp
# or
npm init -y
Создаем аккаунт Auth0:
Создаем tenant/арендатора:
Создаем одностраничное приложение/single page application на вкладке Applications/Applications:
Переходим в раздел Settings:
Создаем файл .env в директории client и записываем в него значения полей Domain и Client ID:
Сервер двухфакторной авторизации linotp
Сегодня я хочу поделиться, как настроить сервер двухфакторной авторизации, для защиты корпоративной сети, сайтов, сервисов,ssh. На сервере будет работать связка: LinOTP FreeRadius.
Зачем он нам?
Это полностью бесплатное, удобное решение, внутри своей сети, не зависящее от сторонних провайдеров.
Данный сервис весьма удобен, достаточно нагляден, в отличии от других опенсорс продуктов, а так же поддерживает огромное количество функций и политик (Например login password (PIN OTPToken)). Через API интегрируется с сервисами отправки sms (LinOTP Config->Provider Config->SMS Provider), генерирует коды для мобильных приложений типа Google Autentificator и многое другое. Я считаю он более удобен чем сервис рассматриваемый в статье.
Данный сервер отлично работает с Cisco ASA, OpenVPN сервером, Apache2, да и вообще практически со всем что поддерживает аутентификацию через RADIUS сервер (Например для SSH в цод).
Требуется:
1 ) Debian 8 (jessie) – Обязательно! (пробная установка на debian 9 описанна в конце статьи)
Начало:
Устанавливаем Debian 8.
Добавляем репозиторий LinOTP:
# echo 'deb http://www.linotp.org/apt/debian jessie linotp' > /etc/apt/sources.list.d/linotp.list
Добавляем ключи:
# gpg --search-keys 913DFF12F86258E5
Иногда при “чистой” установке, после выполнения этой команды, Debian выдает:
gpg: создан каталог `/root/.gnupg'
gpg: создан новый файл настроек `/root/.gnupg/gpg.conf'
gpg: ВНИМАНИЕ: параметры в `/root/.gnupg/gpg.conf' еще не активны при этом запуске
gpg: создана таблица ключей `/root/.gnupg/secring.gpg'
gpg: создана таблица ключей `/root/.gnupg/pubring.gpg'
gpg: не заданы серверы ключей (используйте --keyserver)
gpg: сбой при поиске на сервере ключей: плохой URI
Это первоначальная настройка gnupg. Ничего страшного. Просто выполните команду еще раз.
На вопрос Debiana:
gpg: поиск "913DFF12F86258E5" на hkp сервере keys.gnupg.net
(1) LSE LinOTP2 Packaging <[email protected]>
2048 bit RSA key F86258E5, создан: 2022-05-10
Keys 1-1 of 1 for "913DFF12F86258E5". Введите числа, N) Следующий или Q) Выход>
Отвечаем: 1
Далее:
# gpg --export 913DFF12F86258E5 | apt-key add -
# apt-get update
Устанавливаем mysql. В теории, можно использовать другой sql сервер, но я для простоты буду использовать его, как рекомендованный для LinOTP.
(доп. информация, в том числе о переконфигурировании базы LinOTP можно найти в официальной документации по ссылке. Там же, можно найти команду: dpkg-reconfigure linotp для изменения параметров если вы уже установили mysql).
# apt-get install mysql-server
# apt-get update
(еще раз проверить обновы не помешает)
Устанавливаем LinOTP и доп.модули:
# apt-get install linotp
Отвечаем на вопросы установщика:
Использовать Apache2: да
Придумайте пароль для admin Linotp: «ВашПароль»
Сгенерировать самоподписанный сертефикат?: да
Использовать MySQL?: да
Где находится база данных: localhost
Создаем базу LinOTP(имя базы) на сервере: LinOTP2
Создаем отдельного юзера для базы данных: LinOTP2
Задаем пароль юзеру: «ВашПароль»
Создать ли базу сейчас? (что-то вроде “Вы уверенны что хотите …”): да
Вводим пароль root от MySQL который создали при его установке: «ВашПароль»
Готово.
(опционально, можно и не ставить)
# apt-get install linotp-adminclient-cli
(опционально, можно и не ставить)
# apt-get install libpam-linotp
И так наш веб-интерфейс Linotp теперь доступен по адресу:
"<b>https</b>: //IP_сервера/manage"
О настройках в веб-интерфейсе я расскажу чуть позже.
Теперь, самое важное! Поднимаем FreeRadius и увязываем его с Linotp.
Устанавливаем FreeRadius и модуль работы с LinOTP
# apt-get install freeradius linotp-freeradius-perl
бэкапим конфиги client и Users радиуса.
# mv /etc/freeradius/clients.conf /etc/freeradius/clients.old
# mv /etc/freeradius/users /etc/freeradius/users.old
Создаем пустой файл клиента:
# touch /etc/freeradius/clients.conf
Редактируем наш новый файл конфига (забэкапленный конфиг можно использовать как пример)
# nano /etc/freeradius/clients.conf
client 192.168.188.0/24 {
secret = passwd # пароль для подключения клиентов
}
Далее создаем файл users:
# touch /etc/freeradius/users
Редактируем файл, говоря радиусу, что мы будем использовать perl для аутентификации.
# nano /etc/freeradius/users
DEFAULT Auth-type := perl
Далее редактируем файл /etc/freeradius/modules/perl
# nano /etc/freeradius/modules/perl
Нам нужно прописать путь к perl скрипту linotp в параметре module:
Perl { .......
.........
<source lang="bash">module = /usr/lib/linotp/radius_linotp.pm
…..
Далее создаем файл, в котором говорим из какого (домена, базы или файла) брать данные.
# touch /etc/linotp2/rlm_perl.ini
# nano /etc/linotp2/rlm_perl.ini
URL=https://IP_вашего_LinOTP_сервера(192.168.X.X)/validate/simplecheck
REALM=webusers1c
RESCONF=LocalUser
Debug=True
SSL_CHECK=False
Тут я остановлюсь чуть подробнее, поскольку это важно:
Полное описание файла с комментариями:
#IP of the linotp server (IP адрес нашего LinOTP сервера)
URL=https://172.17.14.103/validate/simplecheck
#Наша область которую мы создадим в веб интерфейсе LinOTP.)
REALM=rearm1
#Имя группы юзверей которая создается в вебморде LinOTP.
RESCONF=flat_file
#optional: comment out if everything seems to work fine
Debug=True
#optional: use this, if you have selfsigned certificates, otherwise comment out (SSL если мы создаем свой сертификат и хотим его проверять)
SSL_CHECK=False
Далее создадим файл /etc/freeradius/sites-available/linotp
# touch /etc/freeradius/sites-available/linotp
# nano /etc/freeradius/sites-available/linotp
И скопируем в него конфиг (править ничего ненадо):
authorize {
#normalizes maleformed client request before handed on to other modules (see '/etc/freeradius/modules/preprocess')
preprocess
# If you are using multiple kinds of realms, you probably
# want to set "ignore_null = yes" for all of them.
# Otherwise, when the first style of realm doesn't match,
# the other styles won't be checked.
#allows a list of realm (see '/etc/freeradius/modules/realm')
IPASS
#understands something like USER@REALM and can tell the components apart (see '/etc/freeradius/modules/realm')
suffix
#understands USERREALM and can tell the components apart (see '/etc/freeradius/modules/realm')
ntdomain
# Read the 'users' file to learn about special configuration which should be applied for
# certain users (see '/etc/freeradius/modules/files')
files
# allows to let authentification to expire (see '/etc/freeradius/modules/expiration')
expiration
# allows to define valid service-times (see '/etc/freeradius/modules/logintime')
logintime
# We got no radius_shortname_map!
pap
}
#here the linotp perl module is called for further processing
authenticate {
perl
}
Далее сделаем сим линк:
# ln -s ../sites-available/linotp /etc/freeradius/sites-enabled
Лично я убиваю дефолтные радиусовские сайты, но если они вам нужны, то можете либо отредактировать их конфиг, либо отключить.
# rm /etc/freeradius/sites-enabled/default
# rm /etc/freeradius/sites-enabled/inner-tunnel
# service freeradius reload
Теперь вернемся к веб-морде и рассмотрим ее чуть подробнее:
В правом верхнем углу нажимаем LinOTP Config -> UserIdResolvers ->New
Выбираем чего хотим: LDAP (AD win, LDAP samba), или SQL, или локальные пользователи системы Flatfile.
Заполняем требуемые поля.
Далее создаем REALMS:
В правом верхнем углу нажимаем LinOTP Config ->Realms ->New.
и даем имя нашему REALMSу, а так же кликаем на созданный ранее UserIdResolvers.
Все эти данные нужны freeRadius в файлике /etc/linotp2/rlm_perl.ini, о чем я писал выше, поэтому, если вы его не отредактировали тогда, сделайте это сейчас.
Все сервер настроен.
Дополнение:
Настройка LinOTP на Debian 9( Спасибо пользователю prikhodkov)
# Добавляем репозитарий LinOTP в /etc/apt/sources.list.d/linotp.list и обновляем репы:
echo «deb linotp.org/apt/debian stretch linotp» > /etc/apt/sources.list.d/linotp.list
apt-get update
apt-get install dirmngr
apt-key adv –recv-keys 913DFF12F86258E5
# Устанавливаем и базово настраиваем mysql сервер:
apt-get install mysql-server
# Устанавливем пакеты linotp и freeradius
apt-get install linotp linotp-adminclient-cli python-ldap freeradius python-passlib python-bcrypt git libio-all-lwp-perl libconfig-file-perl libtry-tiny-perl
# Создаем симлинки на конфигурационные файлы freeradius
ln -s /etc/freeradius/3.0/sites-available /etc/freeradius/sites-available
ln -s /etc/freeradius/3.0/sites-enabled /etc/freeradius/sites-enabled
ln -s /etc/freeradius/3.0/clients.conf /etc/freeradius/clients.conf
ln -s /etc/freeradius/3.0/users /etc/freeradius/users
# Устанавливаем модуль linotp-auth-freeradius-perl
git clone vhod-v-lichnyj-kabinet.ru/LinOTP/linotp-auth-freeradius-perl
cd linotp-auth-freeradius-perl/
cp radius_linotp.pm /usr/share/linotp/radius_linotp.pm
# Приводим файл конфигурации freeradius для linotp к такому виду
cat /etc/freeradius/sites-enabled/linotp
server linotp {
listen {
ipaddr = *
port = 1812
type = auth
}
listen {
ipaddr = *
port = 1813
type = acct
}
authorize {
preprocess
update {
&control:Auth-Type := Perl
}
}
authenticate {
Auth-Type Perl {
perl
}
}
accounting {
unix
}
}
# В sites-enabled для freeradius оставляем только linotp
ls /etc/freeradius/sites-enabled
linotp
# Добавляем хосты с которых разрешаем коннекты на freeradius
cat /etc/freeradius/clients.conf
client host1 {
ipaddr = IP_1
netmask = 32
secret = ‘SECRET_1’
}
client host2 {
ipaddr = IP_2
netmask = 32
secret = ‘SECRET_2’
}
# В качестве базы пользователей используем коннектор perl
cat /etc/freeradius/users
DEFAULT Auth-type := perl
}
cat /etc/freeradius/3.0/mods-available/perl
perl {
filename = /usr/share/linotp/radius_linotp.pm
func_authenticate = authenticate
func_authorize = authorize
}
# В каталог mods-enabled делаем симлинки из каталога mods-available для модуля perl и убираем eap
ln -s /etc/freeradius/3.0/mods-available/perl /etc/freeradius/3.0/mods-enabled/perl
rm /etc/freeradius/3.0/mods-enabled/eap
# Создаем auditkey для запуска linotp
linotp-create-auditkeys -f linotp.ini
# Создаем конфиг коннектора для проверки УД приходящих на радиус
cat /etc/linotp2/rlm_perl.ini
URL=https://IP_LINOTP_SRV/validate/simplecheck
REALM=realm
RESCONF=LocalUser
Debug=True
SSL_CHECK=False
Я оставлю ниже несколько ссылок по настройке систем, которые чаще всего требуется защищать двухфакторной авторизацией:
Настройка двухфакторной аутентификации в Apache2
Настройка с Cisco ASA(там используется другой сервер генерации токенов, но настройки самой ASA теже самые).
VPN c двухфакторной аутентификации
Настройка двухфакторной аутентификации в ssh (там так же используется LinOTP) — спасибо автору. Там же можно найти интересные вещи по настройке политик LiOTP.
Так же cms многих сайтов поддерживают двухфакторную аутентификацию(Для WordPress у LinOTP даже есть свой специальный модуль на github), например, если вы хотите на своем корпоративном сайте сделать защищенный раздел для сотрудников компании.
ВАЖНЫЙ ФАКТ! НЕ ставьте галочку «Google autenteficator» для использования гугл аутентификатора! QR-код не читается тогда… (странный факт)
Для написания статьи использовалась информация из следующих статей:
itnan.ru/post.php?c=1&p=270571
www.digitalbears.net/?p=469
Спасибо авторам.
Создайте свое клиентское приложение
Вернуться к Весне Инициализр . Создайте новый проект со следующими настройками:
- Тип проекта должен быть Gradle (не Maven).
- Группа: com.okta.spring .
- Артефакт: SpringBootOAuthClient .
- Добавьте три зависимости: Web , Thymeleaf , OAuth2 Client .
Загрузите проект, скопируйте его в место последнего распаковывания и распакуйте его.
На этот раз вам нужно добавить следующую зависимость в ваш build.gradle файл:
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.0.4.RELEASE'
Переименование src/main/resources/application.properties в application.yml и обновлять его в соответствии с YAML ниже:
Учётные данные владельца ресурса
- При этом типе разрешения на авторизацию пользователь предоставляет приложению напрямую свои учётные данные (имя пользователя и пароль). Приложение, в свою очередь, использует полученные учётные данные пользователя для получения маркера доступа от сервера авторизации. Этот тип разрешения на авторизацию должен использоваться только в том случае, когда другие варианты не доступны. Кроме того, этот тип разрешения стоит использовать только в случае, когда приложение пользуется доверием пользователя (например, является частью самой системы).
После того, как пользователь передаст свои учётные данные приложению, приложение запросит маркер доступа у авторизационного сервера методом POST.
Параметры запроса:
В ответ на запрос сервер авторизации, вернет объект JSON, который содержит маркер краткосрочного доступа и маркер обновления.
Ответ содержит следующие поля:
Поле | Тип | Описание |
---|---|---|
access_token | STRING | Маркер краткосрочного доступа (сроком действия 1 час). |
expires_in | INTEGER | Оставшееся время жизни маркера доступа в секундах. |
token_type | STRING | Тип возвращаемого маркера. Значение всегда будет Bearer. |
session | STRING | Идентификатор сессии пользователя. |
refresh_token | STRING | Маркер который вы можете использовать для получения нового маркер доступа. |
id_token | STRING | * Маркер пользователя. |
- Обратите внимание, что маркер пользователя возвращается только в том случае, если ваше приложение в запросе к серверу авторизации, установило в
scope
одно из значений: openid, profile или email.
Пример зпроса:
Учётные данные клиента
- Тип разрешения на авторизацию с использованием учётных данных клиента позволяет приложению осуществлять доступ к своему собственному аккаунту сервиса. Это может быть полезно, например, когда приложение хочет обновить собственную регистрационную информацию на сервисе или URI перенаправления, или же осуществить доступ к другой информации, хранимой в аккаунте приложения на сервисе, через API.
Параметры запроса:
В ответ на запрос сервер авторизации, вернет объект JSON, который содержит маркер краткосрочного доступа и маркер обновления.
Ответ содержит следующие поля:
Поле | Тип | Описание |
---|---|---|
access_token | STRING | Маркер доступа (сроком действия 1 день). |
expires_in | INTEGER | Оставшееся время жизни маркера доступа в секундах. |
token_type | STRING | Тип возвращаемого маркера. Значение всегда будет Bearer. |
session | STRING | Идентификатор сессии пользователя. |
refresh_token | STRING | Маркер который вы можете использовать для получения нового маркер доступа. |
id_token | STRING | * Маркер пользователя. |
- Обратите внимание, что маркер пользователя возвращается только в том случае, если ваше приложение в запросе к серверу авторизации, установило в
scope
одно из значений: openid, profile или email.
Пример зпроса:
Настроили форматы вывода пользовательских данных
После того, как выбранные гранты реализованы, авторизация работает, стоит упомянуть о получении данных о конечном пользователе. В OIDC есть отдельный endpoint для этого, на котором со своим текущим токеном доступа и при его актуальности можно запросить данные о пользователях.
И если данные пользователя не меняются так часто, а ходить за текущими нужно по многу раз, можно прийти к такому решению, как JWT-токены. Эти токены также поддерживаются стандартом. Сам по себе JWT-токен состоит из трех частей: header (информация о токене), payload (любые нужные данные) и signature (подпись, токен подписывается сервером и в дальнейшем можно проверить источник его подписи).
В имплементации OIDC JWT-токен называется id_token. Он может быть запрошен вместе с обычным токеном доступа и все, что остается – проверить подпись. У сервера авторизации для этого существует отдельный endpoint со связкой публичных ключей в формате JWK.
И говоря об этом, стоит упомянуть, что существует еще один endpoint, который на основе стандарта RFC5785 отражает текущую конфигурацию OIDC-сервера. В нем содержатся все адреса endpoint’ов (в том, числе адрес связки публичных ключей, используемых для подписи), поддерживаемые клеймы и скоупы, используемые алгоритмы шифрования, поддерживаемые гранты и т.д.
{
"issuer": "https://accounts.google.com",
"authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
"device_authorization_endpoint": "https://oauth2.googleapis.com/device/code",
"token_endpoint": "https://oauth2.googleapis.com/token",
"userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
"revocation_endpoint": "https://oauth2.googleapis.com/revoke",
"jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
"response_types_supported": [
"code",
"token",
"id_token",
"code token",
"code id_token",
"token id_token",
"code token id_token",
"none"
],
"subject_types_supported": [
"public"
],
"id_token_signing_alg_values_supported": [
"RS256"
],
"scopes_supported": [
"openid",
"email",
"profile"
],
"token_endpoint_auth_methods_supported": [
"client_secret_post",
"client_secret_basic"
],
"claims_supported": [
"aud",
"email",
"email_verified",
"exp",
"family_name",
"given_name",
"iat",
"iss",
"locale",
"name",
"picture",
"sub"
],
"code_challenge_methods_supported": [
"plain",
"S256"
],
"grant_types_supported": [
"authorization_code",
"refresh_token",
"urn:ietf:params:oauth:grant-type:device_code",
"urn:ietf:params:oauth:grant-type:jwt-bearer"
]
}
Таким образом с помощью id_token’а можно передавать все нужные клеймы в payload токена и не обращаться каждый раз на сервер авторизации для запроса данных о пользователе. Минусом такого подхода является то, что изменение пользовательских данных от сервера приходит не сразу, а вместе с новым токеном доступа.
Заключение
В статье предложена универсальная модель для авторизации доступа множеству пользователей к множеству ресурсов с минимальными потерями по производительности, позволяющая делать эффективное кеширование и хранение промежуточных данных на микросервисах, для сборки результата авторизации just-in-time.
Предложена метрика (в виде коэффициента), для оценки работы авторизации микросервисов с учетом требований работы всего комплекса и показано, как он может меняться в зависимости от подхода, а именно наличия/отсутствия кеша, хранения ролей в JWT токене и т.д. Не показано, что этот коэффициент всегда растет с увеличением количества под в системе.
Предложенная модель данных позволяет разделить данные между частями микросервисной архитектуры, тем самым снизив требования к железу, дает возможность работать только с теми данными, которые нужны в конкретный момент времени конкретному сервису, не пытаться искать в огромном массиве все подряд или пытаться искать данные в полностью обобщенном хранилище.
Показано, что при малом количестве микросервисов нет смысла проводить кеширование на клиенте авторизации, так как это приведет к большей нагрузке на ЦПУ. Предоставлены данные, которые позволяют сделать вывод, что необходимо балансировать ресурсы в зависимости от размеров системы, если она слишком мала, то есть смысл нагружать больше сеть, но при увеличении и разрастании выгоднее добавить пару ядер в микросервисы, для обеспечения работы кешей и разгрузки сети, микросервисов авторизационных данных.