Pull to refresh

SSO на FreeIPA+Apache+Flask-Login+JWT

Reading time 11 min
Views 14K
Всем привет.

В статье описывается разработка и развёртывание системы SSO-аутентификации, использующей Kerberos и JWT. Модуль аутентификации разработан с применением Flask, Flask-Login и PyJWT. Развёртывание выполнено с использованием веб-сервера Apache, сервера идентификации FreeIPA и модуля mod_lookup_identity на CentOS 6/7. В статье много текста, средне кода и мало картинок. В общем, будет интересно.

image

Немного расскажу про 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>


Логика такова:
  1. На первый запрос пользователя сервер отвечает 401 и просит Negotiate-аутентификацию
  2. Пользователь предоставляет krb-тикет
  3. Сервер запрашивает у sssd информацию о пользователе, устанавливает переменные окружения и передаёт запрос в wsgi-приложение

либо:
  1. На первый запрос пользователя сервер отвечает 401 и просит Negotiate-аутентификацию
  2. Пользователь не предоставляет krb-тикет
  3. Сервер отвечает 401 и просит Basic Auth
  4. Пользователь вводит логин-пароль и успешно аутентифицируется
  5. Сервер запрашивает у 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)


Немножко скриншотов. Главная страница:

image

Печенька свежая, в чём можно убедиться на странице /status:

image

last_good_auth из krb-переменных обновился, так как любой переход между страницами вызывает аутентификацию пользователя через krb-тикет. В jwt параметры exp и nbf не обновились, потому как куку никто и не обновлял. А вот что будет, если кукис удалить:

image

Ну и самое интересное — аутентификация в стороннем приложении. Для демонстрации было написано маленькое и уродливое приложение, которое умеет прочитать кукис и показать либо страницу с данными из 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:

image

После похода за печеньками:

image

Естественно, никто не запрещает редирект сделать автоматическим, вместо показа 403. Более того, демо-приложение изначально так и было написано, но затем для наглядности была прикручена страница с картинками.

Можно ещё поиздеваться над аутентификатором, подставляя ему в параметр запроса data всякий мусор, в том числе устаревшие и/или имеющие некорректные парамеры iss/aud токены — он всё успешно жуёт и ругается. Остаётся последняя нерешённая проблема — как сообщить желающему аутентификации приложению об ошибке? На данный момент рабочая мысль — передавать в запросе URL-callback, на который будет отправлен отчёт об ошибке. Мысль пока единственная, поэтому реализовывать не тороплюсь.

Вторая нерешённая проблема — это selinux. Так как модуль cryptography использует нативные библиотеки, их надо все пометить типом lib_t. Видимо, не все ещё нашёл, так что пока что просто отключил selinux. Добавляю определения типов для файлов через semanage fcontext -a -t <тип> '<regex-путь>'.

Если кого-то заинтересовал полный исходный код, скачать можно здесь. Лицензия — делайте что хотите; если код вам пригодится — то и хорошо.

Ругайте.
Tags:
Hubs:
+7
Comments 7
Comments Comments 7

Articles