http://habrahabr.ru/post/255569/
Всем привет
В статье описывается разработка и развёртывание системы SSO-аутентификации, использующей Kerberos и JWT. Модуль аутентификации разработан с применением Flask, Flask-Login и PyJWT. Развёртывание выполнено с использованием веб-сервера Apache, сервера идентификации FreeIPA и модуля mod_lookup_identity на CentOS 6/7. В статье много текста, средне кода и мало картинок. В общем, будет интересно :)
Немного расскажу про SSO. Single Sign-On (SSO) — принцип аутентификации, позволяющий пользователю ввести пароль только один раз при начале работы с системой и после этого обеспечивающий пользователю беспарольный вход во все приложения домена. На практике 100% SSO встречается очень редко, ибо в организациях часто бывают legacy-системы, которые просто не знают такой аббревиатуры либо не поддерживают современные методы. К возможным методам SSO относятся протокол Kerberos, сертификаты SSL и прочее. Собственно задача аутентификации/проверки токена может возлагаться как на каждое приложение, так и на какой-то центральный сервер аутентификации. Обычно внедрение SSO подразумевает наличие центральной базы данных пользовательских аккаунтов и некое ПО для управления этой базой.
Для Windows-окружения есть стандартное решение, обеспечивающее как SSO, так и централизованную БД пользователей — Active Directory. В linux-мире всё не так однозначно. Был и успешно сдох NIS (но не до конца), есть некоторое количество «стандартных» решений на LDAP, многие (и я тоже) делали какие-то свои надстройки и веб-интерфейсы над OpenLDAP, пытались использовать winbind для связи с AD и так далее. На мой скромный взгляд Red Hat дальше всех ушла в вопросе стандартного «контроллера домена» для Linux, купив и допилив FreeIPA. Продукт разворачивается одной командой, прекрасно работает в RHEL/OEL/CentOS/Fedora-среде (докладывают, что и для Debian есть клиентский модуль), обеспечивает кросс-доменную аутентификацию в AD, управляется целиком через веб-интерфейс, централизует настройки DNS, automount, sudo… Короче, он у меня есть и я с ним счастливо живу.
Тут хочу повториться, что софт я писать не особо умею и не очень люблю, но иногда приходится. И вот писал я убийцу Google Forms, и, естественно, встала задача аутентифицировать пользователя, кою я успешно решил, возложив задачу проверки kerberos-тикета на Apache и запрашивая после этого данные из LDAP (из FreeIPA) для uid из переменной REMOTE_USER. В дальнейшем, применив
mod_lookup_identity, смог даже отказаться от работы с LDAP. Но было в этом решении одно слабое место — пользователи windows и я, заходящие с устройств, не управляемых FreeIPA и, соответственно не имеющие kerberos-тикета (строго говоря, win-пользователи могли бы иметь тикет через изврат с cmd либо через развёртывание AD и cross-domain trust, но ни тем, ни другим извращением заниматься не хотелось).
Давным давно прочитал я про
JSON Web Tokens и всегда чесались руки их попробовать. Вот и представилась возможность. Я порешил сделать так: те, кто имеют krb-тикет, пусть аутентифицируются через Kerberos, а те бедняги, у кого тикета нет, пусть вводят логин-пароль и попадают на Basic-аутентификацию. Тем более, что для Basic Auth есть
mod_authnz_pam, позволяющий вообще забыть про проверку паролей руками. Результат аутентификации будет записываться в cookie в виде JWT, а приложение, запросившее аутентификацию, будет получать эти данные из токена. Соответственно, оформилась потребность в центральном сервисе аутентификации, выдающем JWT.
Для разработки использовались Python и Flask (так как это единственное, на чём я могу разрабатывать более-менее законченные приложения). Для управления аутентификацией в Flask был взят Flask-Login, для работы с jwt —
PyJWT. Ссылка на исходники, если кому нужна, будет в конце.
С подачи моей жены сервис аутентификации был назван Hogwarts' Hat (hh) — та шляпа тоже всё про всех знала.
Для hh был создан свой virtualenv, код был скопирован в корень этого virtualenv, запускается приложение на mod_wsgi. Ниже конфиг апача:
hogwartshat.conf
<VirtualHost *:80>
ServerName hh.gsk.loc
# параметры WSGI-процесса
WSGIDaemonProcess hogwartshat user=hogwartshat group=hogwartshat threads=10
WSGIScriptAlias / /var/www/flask/hogwartshat/hogwartshat.py
WSGIScriptReloading On
# параметры аутентификации
<Location />
AuthType Kerberos
AuthName "HogwartsHat"
# разрешить откат на Basic Auth
KrbDelegateBasic On
KrbServiceName HTTP/garage.gsk.loc@GSK.LOC
KrbMethodNegotiate On
# если отключить следующую директиву - работать перестаёт, почему - не понял
KrbMethodK5Passwd On
KrbAuthRealms GSK.LOC
Krb5KeyTab /etc/httpd/conf/keytab
AuthBasicProvider PAM
# указание на файл конфигурации PAM из /etc/pam.d
AuthPAMService garage
Require valid-user
# Следующие директивы записывают в переменные окружения сведения о пользователе, полученные из sssd через DBus
LookupUserGECOS REMOTE_USER_FULLNAME
LookupUserAttr uid REMOTE_USER_ID
LookupUserAttr krbLastSuccessfulAuth REMOTE_USER_LASTGOODAUTH
LookupUserAttr krbLastFailedAuth REMOTE_USER_LASTBADAUTH
LookupUserGroups REMOTE_USER_GROUPS ":"
# Таймаут меньше 1 с (1000 мс) смысла не имеет - DBus и LDAP просто не успевают отработать в 20-30% случаев
LookupDbusTimeout 2000
</Location>
<Directory /var/www/flask/hogwartshat>
WSGIProcessGroup hogwartshat
WSGIApplicationGroup %{GLOBAL}
</Directory>
LogLevel warn
ErrorLog logs/hogwartshat_error.log
CustomLog logs/hogwartshat_access.log combined
</VirtualHost>
Логика такова:
- На первый запрос пользователя сервер отвечает 401 и просит Negotiate-аутентификацию
- Пользователь предоставляет krb-тикет
- Сервер запрашивает у sssd информацию о пользователе, устанавливает переменные окружения и передаёт запрос в wsgi-приложение
либо:
- На первый запрос пользователя сервер отвечает 401 и просит Negotiate-аутентификацию
- Пользователь не предоставляет krb-тикет
- Сервер отвечает 401 и просит Basic Auth
- Пользователь вводит логин-пароль и успешно аутентифицируется
- Сервер запрашивает у sssd информацию о пользователе, устанавливает переменные окружения и передаёт запрос в wsgi-приложение
В любом другом случае пользователь получает 401 от сервера, что не очень красиво, но зато легко реализовать. Альтернативой мог бы стать
mod_intercept_form_submit, но не хотелось возиться с формами.
wsgi-файл сервиса выглядит так:
hogwartshat.py
#!/usr/bin/env python
# -*- coding: utf8 -*-
import os
import sys
PROJECT_DIR = '/var/www/flask/hogwartshat'
# активация virtualenv (фактически, дописывание в начало PATH каталога с virtualenv)
activate_this = os.path.join(PROJECT_DIR, 'bin', 'activate_this.py')
execfile(activate_this, dict(__file__=activate_this))
sys.path.append(PROJECT_DIR)
from app import app as application
# в instance.py - ключи шифрования
application.config.from_object('app.config')
application.config.from_pyfile('../instance.py')
__init__.py для пакета app тривиален, поэтому рассматривать его здесь не буду. А вот views.py интереснее — там Flask-Login помогает облегчить работу с данными пользователя:
views.py, load_user_from_request()
@login_manager.request_loader
def load_user_from_request(req):
logging.debug('req_loader env vars: %s' % str(req.environ))
uid = req.environ.get('REMOTE_USER')
if uid is None:
login_manager.login_message = 'User is not authenticated by HTTPD'
return None
try:
return HTTPDPoweredUser(
req.environ.get(app.config.get('HTTPD_NAME_ATTR')),
req.environ.get(app.config.get('HTTPD_FULLNAME_ATTR')),
req.environ.get(app.config.get('HTTPD_UID_ATTR')),
req.environ.get(app.config.get('HTTPD_LAST_GOOD_AUTH_ATTR')),
req.environ.get(app.config.get('HTTPD_LAST_FAILED_AUTH_ATTR')),
req.environ.get(app.config.get('HTTPD_GROUPS_ATTR'))
)
except AttributeError:
login_manager.login_message = 'One of the required HTTPD_* attributes not found in request'
return None
Основная идея — свой request_loader, который создаёт объект типа HTTPDPoweredUser из переменных окружения, установленных апачем. В дальнейшем в любой функции, завёрнутой в декоратор login_required, можно получить доступ к информации и пользователе через переменную current_user.
Сервис написан таким образом, что при заходе в / аутентифицированному пользователю выдаётся свежий jwt-кукис следующим образом:
views.py, index()
@app.route('/', methods=['GET'])
@login_required
def index():
if current_user is not None:
cookie = current_user.get_auth_token()
expire_date = datetime.utcnow() + timedelta(hours=app.config.get('JWT_EXPIRE_TIME_HOURS'))
response = make_response(render_template('index.html', user=current_user, cookie=cookie))
response.set_cookie(
app.config.get('JWT_COOKIE_NAME'),
value=cookie,
expires=expire_date,
domain=app.config.get('JWT_COOKIE_DOMAIN'),
path=app.config.get('JWT_COOKIE_PATH'),
secure=app.config.get('SESSION_COOKIE_SECURE')
)
logging.debug('jwt response: %s' % str(response))
return response
else:
abort(403)
users.py, get_auth_token()
def get_auth_token(self):
tokens = {
'exp': datetime.utcnow() + timedelta(hours=app.config.get('JWT_EXPIRE_TIME_HOURS')),
'nbf': datetime.utcnow(),
'iss': app.config.get('JWT_ISSUER_NAME'),
'aud': app.config.get('JWT_URN') + 'all',
'uid': self.uid,
'fullname': self.fullname,
'groups': self.groups
}
logging.debug('jwt tokens: %s' % str(tokens))
cookie = jwt.encode(tokens, app.config.get('JWT_PRIVATE_KEY'), algorithm=app.config.get('JWT_ALG'))
logging.debug('jwt cookie: %s' % str(cookie))
return cookie
Как видно, в токен помимо uid записываются также и ФИО пользователя, и его группы, что избавляет другие приложения от необходимости лазить в центральную БД за инфой о пользователях.
Также у сервиса есть страничка /status, где можно посмотреть на состояние своего jwt:
views.py, status()
@app.route('/status', methods=['GET'])
@login_required
def status():
auth_cookie = request.cookies.get(app.config.get('JWT_COOKIE_NAME'))
logging.debug('cookie: %s' % str(auth_cookie))
tokens = {}
error_message = ''
if auth_cookie is not None:
try:
tokens = jwt.decode(
auth_cookie,
app.config.get('JWT_PUBLIC_KEY'),
audience=app.config.get('JWT_URN') + 'all',
issuer=app.config.get('JWT_ISSUER_NAME')
)
nbf = datetime.utcfromtimestamp(tokens.get('nbf'))
tokens['nbf'] = '(' + str(nbf) + ') ' + str(tokens.get('nbf'))
exp = datetime.utcfromtimestamp(tokens.get('exp'))
tokens['exp'] = '(' + str(exp) + ') ' + str(tokens.get('exp'))
logging.debug('cookie decoded successfully')
except jwt.DecodeError:
logging.debug('status: jwt.DecodeError')
error_message = 'Failed to decode provided JWT'
except jwt.ExpiredSignatureError:
logging.debug('status: jwt.ExpiredSignatureError')
error_message = 'JWT is expired'
except jwt.InvalidIssuerError:
logging.debug('status: jwt.InvalidIssuerError')
error_message = 'JWT is issued by a wrong issuer'
except jwt.InvalidAudienceError:
logging.debug('status: jwt.InvalidAudienceError')
error_message = 'JWT is issued for another audience'
else:
error_message = 'No JWT cookie received'
logging.debug('tokens: %s' % str(tokens))
attr_error = False if current_user is not None else True
return render_template(
'status.html',
error=False if error_message == '' else True,
error_message=error_message,
tokens=tokens,
attr_error=attr_error,
user=current_user
)
Ключи я генерировал так:
openssl ecparam -genkey -name secp521r1 -noout -out hogwartshat_key.pem # p521 - не опечатка
openssl ec -in hogwartshat_key.pem -pubout -out hogwartshat_pub.pem
Потом просто скопировал содержимое pem-файлов в конфиг. Обратите внимание, что PyJWT для работы с асимметричными ключами и эллиптическими кривыми требует модуля cryptography. Радиуса кривизны моих рук не хватило, чтобы запустить PyJWT с предложенными в документации альтернативными модулями.
Ну и, собственно, кусок кода, отвечающий за аутентификацию для сторонних приложений:
views.py, return_to()
@app.route('/return_to', methods=['GET'])
@login_required
def return_to():
app_id = request.args.get('appid')
data = request.args.get('data')
if app_id is None:
return make_error_page('No application ID provided', str(request.url)), 400
elif app_id not in app.config.get('APPS_PUBLIC_KEYS').keys():
return make_error_page('Unknown application ID provided', str(request.url)), 403
if data is None:
return make_error_page('Application provided empty request', str(request.url)), 400
else:
try:
tokens = jwt.decode(
data,
app.config.get('APPS_PUBLIC_KEYS')[app_id],
audience=app.config.get('JWT_ISSUER_NAME'),
issuer=app.config.get('JWT_URN') + app_id
)
return_url = tokens.get('return_url')
if current_user is not None:
cookie = current_user.get_auth_token()
expire_date = datetime.utcnow() + timedelta(hours=app.config.get('JWT_EXPIRE_TIME_HOURS'))
response = make_response(redirect(str(return_url), code=301))
response.set_cookie(
app.config.get('JWT_COOKIE_NAME'),
value=cookie,
expires=expire_date,
domain=app.config.get('JWT_COOKIE_DOMAIN'),
path=app.config.get('JWT_COOKIE_PATH'),
secure=app.config.get('SESSION_COOKIE_SECURE')
)
logging.debug('jwt response: %s' % str(response))
return response
except jwt.DecodeError:
return make_error_page('Failed to decode provided JWT', str(request.url)), 412
except jwt.ExpiredSignatureError:
return make_error_page('JWT is expired', str(request.url)), 412
except jwt.InvalidIssuerError:
return make_error_page('JWT is issued by a wrong issuer', str(request.url)), 412
except jwt.InvalidAudienceError:
return make_error_page('JWT is issued for another audience', str(request.url)), 412
return str(request.args)
Немножко скриншотов. Главная страница:
Печенька свежая, в чём можно убедиться на странице /status:
last_good_auth из krb-переменных обновился, так как любой переход между страницами вызывает аутентификацию пользователя через krb-тикет. В jwt параметры exp и nbf не обновились, потому как куку никто и не обновлял. А вот что будет, если кукис удалить:
Ну и самое интересное — аутентификация в стороннем приложении. Для демонстрации было написано маленькое и уродливое приложение, которое умеет прочитать кукис и показать либо страницу с данными из JWT, либо страницу с ошибкой. Оно настолько маленькое и настолько уродливое, что я просто весь код выложу сюда:
demo, __init__.py
import jwt
import logging.config
from datetime import datetime, timedelta
from flask import Flask, redirect, render_template, get_flashed_messages
from flask_login import LoginManager, UserMixin, login_required, current_user
app = Flask(__name__)
app.config['SECRET_KEY'] = 'the session is unavailable because no secret key was set.'
login_manager = LoginManager()
login_manager.init_app(app)
key = '''-----BEGIN EC PRIVATE KEY-----
-----END EC PRIVATE KEY-----'''
hh_pubkey = '''-----BEGIN PUBLIC KEY-----
-----END PUBLIC KEY-----'''
logging.config.fileConfig('logging.conf')
class JWTPoweredUser(UserMixin):
def __init__(self, fullname, uid, groups):
for attr in [fullname, uid, groups]:
if attr is None:
raise AttributeError('%s cannot be None' % attr.__name__)
self.fullname = fullname
self.uid = uid
self.groups = groups
def is_anonymous(self):
return False
def is_active(self):
return True
def is_authenticated(self):
return True
def get_id(self):
return unicode(self.uid)
@login_manager.request_loader
def load_user_from_request(req):
cookie = req.cookies.get('gsk_auth')
if cookie is None:
login_manager.login_message = 'no cookie'
return None
try:
tokens = jwt.decode(cookie, hh_pubkey, issuer='gsk:hogwartshat', audience='gsk:all')
except jwt.ExpiredSignatureError:
login_manager.login_message = 'expired'
return None
except jwt.DecodeError:
login_manager.login_message = 'decode error'
return None
except jwt.InvalidIssuerError:
login_manager.login_message = 'invalid issuer'
return None
except jwt.InvalidAudienceError:
login_manager.login_message = 'invalid audience'
return None
return JWTPoweredUser(tokens.get('fullname'), tokens.get('uid'), tokens.get('groups'))
@login_manager.unauthorized_handler
def unauthorized():
data = jwt.encode({
'iss': 'gsk:test',
'aud': 'gsk:hogwartshat',
'nbf': datetime.utcnow(),
'exp': datetime.utcnow() + timedelta(minutes=1),
'return_url': 'http://jwttest.gsk.loc'
}, key, algorithm='ES512')
logging.debug('jwt request: %s' % data)
url = 'http://hh.gsk.loc/return_to?appid=test&data=%s' % data
logging.debug('jwt return_to: %s' % url)
page = render_template(
'error.html',
error=login_manager.login_message,
url=url
)
logging.debug('jwt page: %s' % page)
return page, 403
@app.route('/', methods=['GET'])
@login_required
def index():
return render_template('index.html', user=current_user)
Суть та же — кастомный request_loader проверяет токен, а если с ним что-то не так — возвращает None, что заставляет Flask-Login выполнить unauthorized_handler, который тоже кастомный.
Демо без cookie:
После похода за печеньками:
Естественно, никто не запрещает редирект сделать автоматическим, вместо показа 403. Более того, демо-приложение изначально так и было написано, но затем для наглядности была прикручена страница с картинками.
Можно ещё поиздеваться над аутентификатором, подставляя ему в параметр запроса data всякий мусор, в том числе устаревшие и/или имеющие некорректные парамеры iss/aud токены — он всё успешно жуёт и ругается. Остаётся последняя нерешённая проблема — как сообщить желающему аутентификации приложению об ошибке? На данный момент рабочая мысль — передавать в запросе URL-callback, на который будет отправлен отчёт об ошибке. Мысль пока единственная, поэтому реализовывать не тороплюсь.
Вторая нерешённая проблема — это selinux. Так как модуль cryptography использует нативные библиотеки, их надо все пометить типом lib_t. Видимо, не все ещё нашёл, так что пока что просто отключил selinux. Добавляю определения типов для файлов через semanage fcontext -a -t <тип> '<regex-путь>'.
Если кого-то заинтересовал полный исходный код, скачать можно
здесь. Лицензия — делайте что хотите; если код вам пригодится — то и хорошо.
Ругайте :)