Всем привет
В статье описывается разработка и развёртывание системы 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. Ниже конфиг апача:
<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-файл сервиса выглядит так:
#!/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 помогает облегчить работу с данными пользователя:
@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-кукис следующим образом:
@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)
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:
@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 с предложенными в документации альтернативными модулями.
Ну и, собственно, кусок кода, отвечающий за аутентификацию для сторонних приложений:
@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, либо страницу с ошибкой. Оно настолько маленькое и настолько уродливое, что я просто весь код выложу сюда:
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-путь>'.
Если кого-то заинтересовал полный исходный код, скачать можно здесь. Лицензия — делайте что хотите; если код вам пригодится — то и хорошо.
Ругайте :)
Автор: homecreate