FreeIPA + Apache + Flask-Login + JWTでのSSO

みなさんこんにちは。



この記事では、KerberosとJWTを使用したSSO認証の開発と展開について説明します。 認証モジュールは、Flask、Flask-Login、PyJWTを使用して開発されています。 展開は、CentOS 6/7でApache Webサーバー、FreeIPA認証サーバー、およびmod_lookup_identityモジュールを使用して行われました。 この記事には多くのテキスト、中程度のコード、いくつかの写真があります。 一般的に、それは面白いでしょう。



画像



SSOについて少し説明します。 シングルサインオン(SSO)は、システムでの作業を開始するときに一度だけパスワードを入力できる認証原則であり、その後、すべてのドメインアプリケーションへのパスワードなしのアクセスをユーザーに提供します。 実際には、100%SSOは非常にまれです。これは、組織が単にそのような略語を知らないか、最新の方法をサポートしないレガシーシステムを持っていることが多いためです。 可能なSSOメソッドには、Kerberosプロトコル、SSL証明書などが含まれます。 実際、トークンの認証/検証のタスクは、各アプリケーションと中央認証サーバーの両方に割り当てることができます。 通常、SSOの実装は、ユーザーアカウントの中央データベースと、このデータベースを管理するソフトウェアの存在を意味します。



Windows環境の場合、SSOと集中ユーザーデータベースの両方を提供する標準ソリューションであるActive Directoryがあります。 Linuxの世界では、物事はそれほど単純ではありません。 NISも正常に死んでいました(完全ではありません)。LDAPには多くの「標準」ソリューションがあり、多く(および私も)はOpenLDAPでアドオンとWebインターフェースの一部を行い、winbindを使用してADと通信しようとしました。さらに。 私の謙虚な意見では、Red Hatは、FreeIPAを購入して追加した、Linuxの標準的な「ドメインコントローラー」の点で他の誰よりも遠い。 製品は1つのチームで展開され、RHEL / OEL / CentOS / Fedora環境で正常に動作し(Debianのクライアントモジュールもあると報告します)、ADでクロスドメイン認証を提供し、Webインターフェースを介して完全に制御され、DNS設定、自動マウント、 sudo ...要するに、私はそれを手に入れて、幸せに暮らしています。



それから繰り返しますが、ソフトウェアの書き方がよくわからず、ソフトウェアがあまり好きではないことを繰り返しますが、時々それが起こります。 だから私はGoogle Formsのキラーを書いた、そしてもちろん、タスクはユーザーを認証することでした。ケルベロスチケットをApacheにチェックするタスクを割り当て、その後、変数REMOTE_USERからのuidのLDAP(FreeIPAから)のデータを要求することで問題を解決しました 将来的には、 mod_lookup_identityを適用して、LDAPでの作業を拒否することさえできました。 しかし、このソリューションには弱点が1つありました-Windowsユーザーと私はFreeIPAによって管理されていないデバイスから来ており、したがってKerberosチケットを持っていません(厳密に言えば、勝利ユーザーはcmdを使用した倒錯またはADの展開とクロスを介してチケットを持つことができます-ドメインの信頼、しかしこれらの倒錯のいずれにも対処したくありませんでした。



むかしむかし、 JSON Web Tokensについて読み、私の手は常にそれらを試してみました。 そのため、この機会は現れました。 私はこれを行うことにしました。krbチケットを持っている人はKerberosで認証し、チケットを持っていない貧しい人はログインパスワードを入力して基本認証に進みます。 さらに、Basic Authにはmod_authnz_pamがあります 。これにより、パスワードを手動でチェックすることを完全に忘れることができます。 認証結果はCookieにJWTとして記録され、認証を要求したアプリケーションはトークンからこのデータを受け取ります。 したがって、JWTを発行する中央認証サービスのニーズが高まっています。



開発には、PythonとFlaskが使用されました(これが、多かれ少なかれ完全なアプリケーションを開発できる唯一の方法であるため)。 Flask-Loginは、PyJWTのFlaskで認証を管理し、jwtと連携するために使用されました 。 ソースへのリンクが必要な場合は、最後にあります。



妻のリクエストで、認証サービスはホグワーツの帽子(hh)と呼ばれました。その帽子はすべての人のすべてを知っていました。



hhの場合、独自のvirtualenvが作成され、コードがこのvirtualenvのルートにコピーされ、mod_wsgi上のアプリケーションが起動されます。 以下はApacheの設定です:

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

   #認証パラメーター
   <場所/>
     AuthType Kerberos
     AuthName "HogwartsHat"

     #基本認証へのロールバックを有効にする
     KrbDelegateBasicオン

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

     #次のディレクティブを無効にした場合-動作を停止した理由-理解できなかった
     KrbMethodK5Passwdオン

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

     #/etc/pam.dからのPAM設定ファイルの表示
     AuthPAMServiceガレージ

    有効なユーザーが必要

     #次のディレクティブは、DBusを介してsssdから取得したユーザー情報を環境変数に書き込みます
     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
   </場所>

   <ディレクトリ/ var / www / flask / hogwartshat>
     WSGIProcessGroup hogwartshat
     WSGIApplicationGroup%{GLOBAL}
   </ Directory>
  ログレベル警告
   ErrorLogログ/ hogwartshat_error.log
   CustomLogログ/ hogwartshat_access.logの組み合わせ
 </ VirtualHost>




ロジックは次のとおりです。

  1. サーバーは最初のユーザー要求に401で応答し、ネゴシエート認証を要求します
  2. ユーザーがkrbチケットを提供します
  3. サーバーはsssdユーザー情報を要求し、環境変数を設定し、要求をwsgiアプリケーションに渡します


どちらか:

  1. サーバーは最初のユーザー要求に401で応答し、ネゴシエート認証を要求します
  2. ユーザーはkrbチケットを提供しません
  3. サーバーは401に応答し、基本認証を要求します
  4. ユーザーはログインパスワードを入力し、認証に成功します。
  5. サーバーはsssdユーザー情報を要求し、環境変数を設定し、要求をwsgiアプリケーションに渡します


それ以外の場合、ユーザーはサーバーから401を受け取ります。これはあまり良いことではありませんが、実装は簡単です。 別の方法はmod_intercept_form_submitですが、フォームを台無しにしたくありませんでした。



サービスwsgiファイルは次のようになります。

hogwartshat.py
 #!/ usr / bin / env python
 #-*-コーディング:utf8-*-

輸入OS
インポートシステム

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

 #virtualenvのアクティベーション(実際、virtualenvを使用してPATHディレクトリの先頭に追加)
 activate_this = os.path.join(PROJECT_DIR、「bin」、「activate_this.py」)
 execfile(activate_this、dict(__ file __ = activate_this))
 sys.path.append(PROJECT_DIR)

アプリとしてアプリをアプリとしてインポート

 #in instance.py-暗号化キー
 application.config.from_object( 'app.config')
 application.config.from_pyfile( '../ instance.py')






__init__.pyはアプリパッケージにとって簡単なので、ここでは検討しません。 ただし、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 none:login_manager.login_mess =「ユーザーはHTTPDで認証されていません」 )、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'))))AttributeError:を除く 




主なアイデアはrequest_loaderで、Apacheによって設定された環境変数からHTTPDPoweredUser型のオブジェクトを作成します。 将来、login_requiredデコレータでラップされた関数では、current_user変数を介して情報とユーザーにアクセスできます。



サービスは、/認証済みユーザーにログインすると、次のように新しいjwt Cookieが発行されるように作成されます。



views.py、インデックス()
 @ app.route( '/'、methods = ['GET'])
 @login_required
 defインデックス():
     current_userが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')、
            値= 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))
        応答を返す
    その他:
        中止(403)




users.py、get_auth_token()
     def get_auth_token(self):
        トークン= {
             'exp':datetime.utcnow()+ timedelta(時間= 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、
             「グループ」:self.groups
         }
         logging.debug( 'jwt tokens:%s'%str(tokens))
         cookie = jwt.encode(トークン、app.config.get( 'JWT_PRIVATE_KEY')、アルゴリズム= app.config.get( 'JWT_ALG'))
         logging.debug( 'jwt cookie:%s'%str(cookie))
        クッキーを返す




ご覧のとおり、uidに加えて、ユーザーとそのグループの名前もトークンに書き込まれているため、他のアプリケーションがユーザーに関する情報を得るために中央データベースにアクセスする必要がありません。



また、サービスには/ statusページがあり、jwtの状態を確認できます。



views.py、ステータス()
 @ app.route( '/ status'、methods = ['GET'])
 @login_required
 defステータス():
     auth_cookie = request.cookies.get(app.config.get( 'JWT_COOKIE_NAME'))
     logging.debug( 'cookie:%s'%str(auth_cookie))
    トークン= {}
     error_message = ''
     auth_cookieがNoneでない場合:
        試してください:
            トークン= jwt.decode(
                 auth_cookie
                 app.config.get( 'JWT_PUBLIC_KEY')、
                オーディエンス= app.config.get( 'JWT_URN')+ 'all'、
                発行者= app.config.get( 'JWT_ISSUER_NAME')
             )
             nbf = datetime.utcfromtimestamp(tokens.get( 'nbf'))
            トークン['nbf'] = '(' + str(nbf)+ ')' + str(tokens.get( 'nbf'))
             exp = datetime.utcfromtimestamp(tokens.get( 'exp'))
            トークン['exp'] = '(' + str(exp)+ ')' + str(tokens.get( 'exp'))
             logging.debug(「Cookieが正常にデコードされました」)
         jwt.DecodeErrorを除く:
             logging.debug( 'status:jwt.DecodeError')
             error_message = '提供されたJWTのデコードに失敗しました'
         jwt.ExpiredSignatureErrorを除く:
             logging.debug( 'status:jwt.ExpiredSignatureError')
             error_message = 'JWTは期限切れです'
         jwt.InvalidIssuerErrorを除く:
             logging.debug( 'status:jwt.InvalidIssuerError')
             error_message = 'JWTは間違った発行者によって発行されました'
         jwt.InvalidAudienceErrorを除く:
             logging.debug( 'status:jwt.InvalidAudienceError')
             error_message =「JWTは別の対象者に対して発行されます」
    その他:
         error_message = 'JWT Cookieを受信して​​いません'
     logging.debug( 'tokens:%s'%str(tokens))
     attr_error = current_userがNoneでない場合はfalse、そうでない場合はtrue
     render_template(
         「status.html」、
         error_message == ''の場合、error = false、true、
         error_message = error_message、
        トークン=トークン、
         attr_error = attr_error、
        ユーザー= 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では、非対称キーと楕円曲線を扱う暗号化モジュールが必要であることに注意してください。 私の手の曲率半径は、ドキュメントで提案されている代替モジュールでPyJWTを起動するには不十分でした。



実際、サードパーティアプリケーションの認証を担当するコードは次のとおりです。



views.py、return_to()
 @ app.route( '/ return_to'、methods = ['GET'])
 @login_required
 def return_to():
     app_id = request.args.get( 'appid')
    データ= request.args.get( 'data')
     app_idがNoneの場合:
         return make_error_page(「アプリケーションIDが指定されていません」、str(request.url))、400
     elif app_idがapp.config.getにありません( 'APPS_PUBLIC_KEYS')。keys():
         return make_error_page(「不明なアプリケーションIDが提供されました」、str(request.url))、403
    データがNoneの場合:
         return make_error_page(「アプリケーションは空のリクエストを提供しました」、str(request.url))、400
    その他:
        試してください:
            トークン= jwt.decode(
                データ、
                 app.config.get( 'APPS_PUBLIC_KEYS')[app_id]、
                オーディエンス= app.config.get( 'JWT_ISSUER_NAME')、
                発行者= app.config.get( 'JWT_URN')+ app_id
             )
             return_url = tokens.get( 'return_url')
             current_userがNoneでない場合:
                 cookie = current_user.get_auth_token()
                 expire_date = datetime.utcnow()+ timedelta(hours = app.config.get( 'JWT_EXPIRE_TIME_HOURS'))
                応答= make_response(リダイレクト(str(return_url)、コード= 301))
                 response.set_cookie(
                     app.config.get( 'JWT_COOKIE_NAME')、
                    値= 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))
                応答を返す
         jwt.DecodeErrorを除く:
             return make_error_page(「提供されたJWTのデコードに失敗しました」、str(request.url))、412
         jwt.ExpiredSignatureErrorを除く:
             return make_error_page( 'JWT is expired'、str(request.url))、412
         jwt.InvalidIssuerErrorを除く:
             return make_error_page(「JWTは間違った発行者によって発行されました」、str(request.url))、412
         jwt.InvalidAudienceErrorを除く:
             return make_error_page(「JWTは別のオーディエンスに対して発行されます」、str(request.url))、412
     return str(request.args)




いくつかのスクリーンショット。 メインページ:



画像



/ステータスページで確認できるように、Cookieは新鮮です。



画像



ページ間の遷移によりkrbチケットを介したユーザー認証が発生するため、krb変数のlast_good_authが更新されました。 jwtでは、誰もCookieを更新しなかったため、expおよびnbfパラメーターは更新されませんでした。 しかし、Cookieが削除されるとどうなりますか:



画像



さて、最も興味深いのは、サードパーティアプリケーションでの認証です。 実証するために、Cookieを読み取り、JWTからのデータページまたはエラーページを表示できる、小さくてandいアプリケーションを作成しました。 それは非常に小さくてthatいので、ここにすべてのコードを配置します。



デモ、__ init__.py
インポートjwt
 logging.configのインポート
日時インポート日時、timedeltaから

フラスコからフラスコをインポート、リダイレクト、render_template、get_flashed_messages
 flask_loginからLoginManager、UserMixin、login_required、current_userをインポートします

 app = Flask(__ name__)
 app.config ['SECRET_KEY'] = '秘密鍵が設定されていないため、セッションは利用できません。

 login_manager = LoginManager()
 login_manager.init_app(アプリ)

キー= '' '----- ECプライベートキーの開始-----
 ----- ECプライベートキーの終了----- '' '

 hh_pubkey = '' '-----公開キーの開始-----
 -----パブリックキーの終了----- '' '

 logging.config.fileConfig( 'logging.conf')


クラスJWTPoweredUser(UserMixin):
     def __init __(self、fullname、uid、groups):
         [フルネーム、uid、グループ]のattrの場合:
             attrがNoneの場合:
                 AttributeErrorを発生させます( '%sをNoneにすることはできません'%attr .__ name__)
         self.fullname = fullname
         self.uid = uid
         self.groups =グループ

     def is_anonymous(self):
        偽を返す

     def is_active(self):
        真を返す

     def is_authenticated(self):
        真を返す

     def get_id(自己):
        ユニコードを返す(self.uid)


 @ login_manager.request_loader
 def load_user_from_request(req):
     cookie = req.cookies.get( 'gsk_auth')
     CookieがNoneの場合:
         login_manager.login_message = 'Cookieなし'
        何も返さない
    試してください:
        トークン= jwt.decode(cookie、hh_pubkey、発行者= 'gsk:hogwartshat'、対象者= 'gsk:all')
     jwt.ExpiredSignatureErrorを除く:
         login_manager.login_message = '期限切れ'
        何も返さない
     jwt.DecodeErrorを除く:
         login_manager.login_message = 'デコードエラー'
        何も返さない
     jwt.InvalidIssuerErrorを除く:
         login_manager.login_message = '無効な発行者'
        何も返さない
     jwt.InvalidAudienceErrorを除く:
         login_manager.login_message =「無効な対象者」
        何も返さない
     return JWTPoweredUser(tokens.get( 'fullname')、tokens.get( 'uid')、tokens.get( 'groups'))


 @ login_manager.unauthorized_handler
 def無許可():
    データ= jwt.encode({
         「iss」:「gsk:テスト」、
         「aud」:「gsk:hogwartshat」、
         'nbf':datetime.utcnow()、
         'exp':datetime.utcnow()+ timedelta(分= 1)、
         'return_url': 'http://jwttest.gsk.loc'
     }、キー、アルゴリズム= '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」、
        エラー= login_manager.login_message、
         url = url
     )
     logging.debug( 'jwt page:%s'%ページ)
    戻りページ、403


 @ app.route( '/'、methods = ['GET'])
 @login_required
 defインデックス():
     render_template( 'index.html'、user = current_user)を返します




本質は同じです-カスタムrequest_loaderはトークンをチェックし、何か問題がある場合はNoneを返します。これにより、Flask-Loginは無許可のhandler_handlerを実行しますが、これもカスタムです。



Cookie無料デモ:



画像



クッキーを探した後:



画像



当然、403を表示する代わりに、リダイレクトを自動化することを禁止する人はいません。さらに、デモアプリケーションは元々そのように書かれていましたが、わかりやすくするために、画像ページはねじ込まれました。



古いものや不正なiss / audトークンを含むデータ要求パラメーターのごみを置き換えることで、オーセンティケーターをモックすることができます-彼は常にかみ砕いて誓います。 最後の未解決の問題が残っています-認証が必要なアプリケーションにエラーを報告する方法は? 現時点では、エラーレポートの送信先のリクエストでURLコールバックを渡すことが考えられます。 これまでのところ唯一の考えでしたので、私はそれを実装するために急いではありません。



2番目の未解決の問題はselinuxです。 暗号化モジュールはネイティブライブラリを使用するため、すべてlib_tタイプでマークする必要があります。 どうやら、私はまだそれを見つけられなかったので、今のところselinuxをオフにしました。 semanage fcontext -a -t <type> '<regex-path>'を使用してファイルのタイプ定義を追加します。



誰かが完全なソースコードに興味がある場合は、 こちらからダウンロードできます。 ライセンス-必要なことを行います。 コードがあなたにとって有用であれば-それは良いことです。



Scる。



All Articles