📜 ⬆️ ⬇️

SSO on FreeIPA + Apache + Flask-Login + JWT

Hello.

This article describes the development and deployment of an SSO authentication system using Kerberos and JWT. The authentication module is developed using Flask, Flask-Login and PyJWT. The deployment was done using Apache web server, FreeIPA authentication server and mod_lookup_identity module on CentOS 6/7. The article has a lot of text, medium code and few pictures. In general, it will be interesting.

image

I'll tell you a little about SSO. Single Sign-On (SSO) is an authentication principle that allows a user to enter a password only once when he starts working with the system and then provides passwordless access to all domain applications for the user. In practice, 100% SSO is very rare, because organizations often have legacy systems that simply do not know such an abbreviation or do not support modern methods. Possible SSO methods include the Kerberos protocol, SSL certificates, and more. Actually, the task of authentication / verification of a token can be assigned both to each application and to some kind of central authentication server. Typically, the implementation of SSO implies a central database of user accounts and some software to manage this database.
')
For the Windows environment, there is a standard solution that provides both SSO and a centralized user database - Active Directory. In the linux world, things are not so straightforward. NIS was successfully dead (but not completely), there are a number of “standard” LDAP solutions, many (and I also) made some kind of add-ons and web interfaces over OpenLDAP, tried to use winbind to communicate with AD and so Further. In my humble opinion, Red Hat has gone farthest in the question of the standard “domain controller” for Linux, having bought and finished FreeIPA. The product is deployed as a single command, works great in the RHEL / OEL / CentOS / Fedora environment (it is reported that there is a client module for Debian), provides cross-domain authentication in AD, is controlled entirely through a web interface, centralizes the DNS settings, automount, sudo ... In short, I have it and I live with it happily.

I want to repeat here that I don’t really know how to write software and I don’t really like it, but sometimes I have to. And so I wrote the Google Forms killer, and, naturally, the task arose of authenticating the user, which I successfully solved, having assigned the task of checking the kerberos-ticket to Apache and then requesting data from LDAP (from FreeIPA) for the uid from the REMOTE_USER variable. Later, using mod_lookup_identity , I could even refuse to work with LDAP. But there was one weak point in this solution - windows users and I calling from devices not managed by FreeIPA and, accordingly, not having a kerberos-ticket (strictly speaking, win-users could have a ticket through a pervert with cmd or through the deployment of AD and cross -domain trust, but neither wanted to be concerned with any other distortion).

I read a long time ago about JSON Web Tokens and always itched my hands to try them. That opportunity presented itself. I decided to do this: those who have a krb-ticket, let them authenticate via Kerberos, and those poor fellows who do not have a ticket, let them enter the login-password and get into Basic-authentication. Moreover, for Basic Auth there is a mod_authnz_pam , which allows you to completely forget about checking passwords with your hands. The authentication result will be written to the cookie in the form of JWT, and the application requesting authentication will receive this data from the token. Accordingly, there was a need for a central authentication service issuing JWT.

For development, Python and Flask were used (since this is the only way I can develop more or less complete applications). To control authentication in Flask, Flask-Login was taken, to work with jwt - PyJWT . Link to the source, if anyone needs, will be at the end.

With the filing of my wife, the authentication service was called Hogwarts' Hat (hh) - that hat knew everything about everyone.

For hh, your virtualenv was created, the code was copied to the root of this virtualenv, the application is started on mod_wsgi. Below is the Apache config:
hogwartshat.conf
 <VirtualHost *: 80>
   ServerName hh.gsk.loc

   # WSGI process settings
   WSGIDaemonProcess hogwartshat user = hogwartshat group = hogwartshat threads = 10
   WSGIScriptAlias ​​/ /var/www/flask/hogwartshat/hogwartshat.py
   WSGIScriptReloading On

   # authentication parameters
   <Location />
     AuthType Kerberos
     AuthName "HogwartsHat"

     # allow rollback on Basic Auth
     KrbDelegateBasic On

     KrbServiceName HTTP/garage.gsk.loc@GSK.LOC
     KrbMethodNegotiate On

     # if you disable the following directive - it stops working, why - I do not understand
     KrbMethodK5Passwd On

     KrbAuthRealms GSK.LOC
     Krb5KeyTab / etc / httpd / conf / keytab
     AuthBasicProvider PAM

     # pointing to the PAM configuration file from /etc/pam.d
     AuthPAMService garage

     Require valid-user

     # The following directives write user information from sssd through DBus to environment variables
     LookupUserGECOS REMOTE_USER_FULLNAME
     LookupUserAttr uid REMOTE_USER_ID
     LookupUserAttr krbLastSuccessfulAuth REMOTE_USER_LASTGOODAUTH
     LookupUserAttr krbLastFailedAuth REMOTE_USER_LASTBADAUTH
     LookupUserGroups REMOTE_USER_GROUPS ":"

     # Timeout is less than 1 s (1000 ms) does not make sense - DBus and LDAP just do not have time to work out in 20-30% of cases
     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>


The logic is:
  1. The server responds to the first user request 401 and asks for Negotiate authentication.
  2. User provides krb-ticket
  3. The server requests sssd information about the user, sets the environment variables and sends the request to the wsgi application.

or:
  1. The server responds to the first user request 401 and asks for Negotiate authentication.
  2. User does not provide krb-ticket
  3. The server answers 401 and asks for Basic Auth
  4. User enters login-password and successfully authenticates
  5. The server requests sssd information about the user, sets the environment variables and sends the request to the wsgi application.

In any other case, the user receives 401 from the server, which is not very nice, but easy to implement. An alternative would be mod_intercept_form_submit , but I didn’t want to mess around with forms.

The service wsgi file looks like this:
hogwartshat.py
 #! / usr / bin / env python
 # - * - coding: utf8 - * -

 import os
 import sys

 PROJECT_DIR = '/ var / www / flask / hogwartshat'

 # activate virtualenv (in fact, appending to the beginning of the PATH directory with 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

 # in instance.py - encryption keys
 application.config.from_object ('app.config')
 application.config.from_pyfile ('../ instance.py')



__init__.py for the app package is trivial, so I will not consider it here. But views.py is more interesting - there Flask-Login helps to facilitate the work with user data:
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.lo API = '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 = 'This is not found 


The basic idea is your request_loader, which creates an HTTPDPoweredUser object from the environment variables set by the apache. In the future, in any function wrapped in the login_required decorator, you can access the information and the user through the current_user variable.

The service is written in such a way that when logging into / authenticated user, a fresh jwt-cookie is issued as follows:

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


As you can see, in addition to the uid, the user's full name and his group are also recorded, which saves other applications from having to climb into the central database for information about users.

Also, the service has a page / status, where you can look at the status of your 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
     )


I generated the keys like this:
 openssl ecparam -genkey -name secp521r1 -noout -out hogwartshat_key.pem # p521 - not a typo
 openssl ec -in hogwartshat_key.pem -pubout -out hogwartshat_pub.pem

Then I just copied the contents of the pem-files into the config. Note that PyJWT for working with asymmetric keys and elliptic curves requires a cryptography module. The radius of curvature of my hands was not enough to run PyJWT with the alternative modules suggested in the documentation.

Well, actually, a piece of code responsible for authentication for third-party applications:

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)


A few screenshots. Home Page:

image

The cookie is fresh, as can be seen on the / status page:

image

The last_good_auth from krb variables has been updated, since any transition between pages causes user authentication through the krb-ticket. In jwt, the exp and nbf parameters were not updated, because nobody updated the cookie. But what will happen if cookies are deleted:

image

Well, the most interesting thing is authentication in a third-party application. For the demonstration, a small and ugly application was written that can read cookies and display either a page with data from JWT or a page with an error. It is so small and so ugly that I just put all the code here:

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'] = 'it was the secret session.

 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)


The essence is the same - the custom request_loader checks the token, and if something is wrong with it, returns None, which causes the Flask-Login to perform an unauthorized_handler, which is also a custom one.

Demo without cookie:

image

After trekking for cookies:

image

Naturally, no one forbids redirecting to make automatic, instead of showing 403. Moreover, the demo application was originally written this way, but then for clarity, the page with the pictures was bolted.

You can still make fun of the authenticator, substituting any garbage, including outdated and / or invalid iss / aud parameters, to the data request parameter - it is chewing and swearing successfully. The last unsolved problem remains - how to inform the application that wants to authenticate to the application? At the moment, the working idea is to send the URL-callback in the request, to which an error report will be sent. The idea is the only one, so I'm not in a hurry to realize.

The second unsolved problem is selinux. Since the cryptography module uses native libraries, they should all be marked with the type lib_t. Apparently, not everything has been found, so for now, I just turned off selinux. I add type definitions for files via semanage fcontext -a -t <type> '<regex-path>'.

If someone is interested in the full source code, you can download it here . License - do what you want; If the code is useful to you, that's good.

Scold.

Source: https://habr.com/ru/post/255569/


All Articles