Django + pyradで、Windows NPSを使ってUser-Password方式のRADIUS認証をしてみた

前回はActiveDirectoryを使ったLDAPでの認証を行いました。

他に認証方法がないかを探してみたところ、過去にWindows Serverの機能であるネットワークポリシーサーバー(以下、NPS)のRADIUSを使って、無線LANSSTPL2TPなどの環境構築をしたことを思い出しました。

そこで今回は、DjangoRADIUS認証を試してみることにしました。

 

環境

開発環境

 

ActiveDirectory環境
  • Windows Server 2008 R2 (ドメインコントローラとNPSを兼任)
  • NPS機能を有効化 (RADIUSサーバとして動作)
  • ユーザー情報
    • ログイン名: fugafuga
    • パスワード: fugafuga
    • 所属グループ: RadiusUserGroup

 

事前調査

Pythonライブラリについて

PyPiや以下を参考にPython3でも動作しそうなライブラリを探してみました。

 
その中でpyradがPython3.4.3でも動作したため、このライブラリを使うことにしました。
wichert/pyrad - GitHub

 

認証方式について

オライリーRADIUS本では、RADIUSではUser-Password方式とCHAP-Password方式が挙げられていました。

pyradライブラリのコードを見たところ、User-Password方式は実装されてましたが、CHAP-Password方式は未実装でした。

そこで、MS-CHAP部分をうまいことやるライブラリがないか探してみたところ、

あたりが見つかりました。

ただ、SyntaxError: invalid syntaxエラーが出るなど、いずれもそのままではPython3の環境では使えませんでした。

 
また、以下のRFCなどを読んで実装することも考えましたが、今回は時間がなかったため、CHAP-Password方式を試すのは諦めました。

なお、MS-CHAP v2には脆弱性も見つかっているので、使うときには注意が必要です。
MS-CHAPv2プロトコルの破綻 | セキュリティ情報 | 株式会社ラック

 
他に、EAP over RADIUSまわりも調べようとしましたが、こちらも時間切れで調べきれませんでした。

 
これらを元に、今回はUser-Password方式のRADIUS認証を試すことにしました。

 

NPSの設定

今回はNPSをRADIUSサーバーとして使います。

そこで、Windows Serverにネットワークポリシーサーバー(NPS)の役割を追加をしている前提で、以下を参考に作業を行います。

 

RADIUSクライアントの追加

NPSのRADIUSクライアントとして、Djangoアプリを追加します。

ネットワークポリシーとアクセスサービス > NPS (ローカル) > RADIUS クライアントとサーバー > RADIUSクライアントを選択、一番右のペインで新規をクリックして、以下の設定を行います。

項目
フレンドリ名 DjangoRadiusClient (任意の値)
アドレス DjangoIPアドレス
共有シークレット a

 

接続要求ポリシーの追加

DjangoアプリからRADIUSサーバへの接続要求を許可するため、接続要求ポリシーを新規作成します。

ネットワークポリシーとアクセスサービス > NPS (ローカル) > ポリシー > 接続要求ポリシー を選択し、一番右のペインの接続要求ポリシー新規をクリックします。

今回はPAPでユーザー認証をすることから、PAPを使う時のプロトコルPPPで接続要求があった時にのみ接続を許可します。

項目
ポリシー名 DjangoConnectionRequestPolicy (任意の値)
条件 - 値 FramedProtocol - PPP
認証 このサーバーで要求を認証する

なお、IPアドレスで絞るときには正規表現も使えそうでした。
NPS で正規表現を使用する - TechNet

 

ネットワークポリシーの追加

接続時の認証に関するネットワークポリシーを追加します。

今回は、ActiveDirectoryのユーザーグループRadiusUserGroupに属していれば認証できるようにします。そのため、事前に RadiusUserGroup をグローバルセキュリティなユーザーグループとして作成しておきます。

また、RADIUSのUser-Password方式でユーザーを認証するため、認証方法はPAPのみを指定しておきます。

項目
ポリシー名 DjangoNetWorkPolicy (任意の値)
アクセス許可 アクセスを許可する。...
条件 - 値 ユーザーグループ - RadiusUserGroup
認証方法 暗号化されていない認証(PAP、SPAP)のみチェックを入れる
RADIUS属性 - Framed-Protocol PPP
RADIUS属性 - Service-Type Framed

 

ログファイルの設定

上記の手順までで接続できますが、RADIUS認証でエラーが発生した場合のエラーログを見やすくするため、以下を参考にログファイルの設定を変更します。
ログ ファイルのプロパティを構成する - TechNet

ネットワークポリシーとアクセスサービス > NPS (ローカル) > アカウンティング を選択し、ログファイルプロパティの変更 をクリックします。

ログファイルタブで形式を選択できますが、XML形式だと項目名も分かることから、DTS標準を選択します。

 
以上でWindows Server側の設定は完了です。

 

Djangoアプリの実装

Django向けのライブラリとしては、django-radiusがありましたが、今回もバックエンドを自作してみることにしました。

RADIUSクライアント向けのバックエンドを実装するにあたり、以下が参考になりました。ありがとうございました。
Django の認証を RADIUS で。 - Twisted Mind

また、その他にも悩んだところがあったため、まとめておきます。

 

Dictionaryについて

pyradではRADIUS辞書が必要になりますが、手元には適当な辞書がありませんでした。

Clientでdictパラメータを指定せずにCreateAuthPacket()を実行すると、 AttributeError: 'NoneType' object has no attribute 'attributes' のエラーが発生します。

そこで、pyradのサンプルにあるdictionaryファイル(pyrad/dictionary at master · wichert/pyrad)をコピーし、Djangoアプリのルートディレクトリ直下へと置きます。

次に、以下を参考に$INCLUDE行を削除します。
[ 備忘録 /python ] pyrad のサンプルファイルを使うとエラーになる: Fomalhaut of Piscis Australis

あとはdictにdictionaryファイルを指定し、動作を確認します。

 

secretの指定について

pyrad.client.Clientを使う際、secretパラメータへNPSとの共有シークレットを指定する必要があります。

ただ、pyradのREADMEのように secret="s3cr3t"と指定したところ、pyrad.packet84行目あたりでTypeError: secret must be a binary stringエラーが発生しました。

コードを見たところ、Clientの__init__の引数secretsecret=six.b('')となっていたため、手元の実装コードでもsix.b()を使うようにしました。
Six: Python 2 and 3 Compatibility Library — six 1.9.0 documentation

 

Framed-Protocol属性について

今回はNPSのポリシーでPPP接続に限っているため、Django側でFramed-Protocol属性を指定しない場合、RADIUS認証に失敗してNPSログにReason-Code値49が記録されます。

そのため、Django側で req['Framed-Protocol'] = 1と指定します。

なお、今回はPPPのため、Framed-Protocol属性は1となっています。
Radius Types - IANA

 

User-Password方式の場合のパスワードの暗号化

ライブラリのREADMEにも記載されていますが、pyrad.packet.AuthPacket.PwCrypt()を使います。

 

最終的な実装コード

DjangoAUTHENTICATION_BACKENDSとして指定するクラスは以下の通りとなります。

from django.contrib.auth import get_user_model
from django.conf import settings
from pyrad.client import Client
from pyrad.dictionary import Dictionary
from pyrad.packet import AccessRequest, AccessAccept
import six
import os

class RadiusPAPBackend(object):
    def authenticate(self, username=None, password=None):
        srv = Client(
            server=settings.AD_NPS_HOST_NAME,
            secret=six.b(settings.AD_NPS_SHARED_SECRETS),
            dict=Dictionary(os.path.join(settings.BASE_DIR, "dictionary")),
        )
        req = srv.CreateAuthPacket(
            code=AccessRequest,
            User_Name=username,
        )
        req['Framed-Protocol'] = 1
        req['User-Password'] = req.PwCrypt(password)

        try:
            reply = srv.SendPacket(req)
            if reply.code == AccessAccept:
                user = get_user_model()
                result, created = user.objects.update_or_create(
                    username = username,
                    password = password
                )
                return result
            else:
                return None
        except:
            return None


    def get_user(self, user_id):
        user = get_user_model()
        try:
            return user.objects.get(pk=user_id)
        except:
            return None

 

その他の部分の実装
  • 上記のRadiusPAPBackendクラスをsettings.pyにあるAUTHENTICATION_BACKENDSへ設定
  • それ以外のコードは前回とほぼ同じ流れで実装

を実装したところ、ログイン/ログアウトが正常に動作しました。

 

トラブルシューティング

うまく動作しない時に自分が確認したものをまとめます。

トラブルシューティング時に確認した場所は、

  • DjangoアプリでのSendPacket()の戻り値である pyrad.packet.Packet.code
  • NPSのログのReason-Code

です。

なお、NPSログで使われるタグ、およびReason-Codeは以下を参考にしています。

 

NPSとDjangoアプリでsecretが異なる場合
  • Djangoアプリ
    • SendPacket()後に、pyrad.client.Timeoutのエラーが発生
  • NPSのログ
    • Reason-Codeが16

16の場合、「認証は、ユーザーの資格情報の不一致により失敗しました。 指定されたユーザー名は、既存のユーザー アカウントと一致しないか、パスワードが間違っています」のようです。

 

NPSの接続要求ポリシーに合致しない場合
  • Djangoアプリ
    • pyrad.packet.Packet.codeが3(AccessReject)
  • NPSのログ
    • Proxy-Policy-Nameタグが存在しない
    • Reason-Codeが49

49の場合、「ネットワーク ポリシー サーバーで、接続要求が拒否されましたので、構成された接続要求ポリシー、接続要求が一致しません」のようです。

 

NPSのネットワークポリシーに合致しない場合
  • Djangoアプリ
    • pyrad.packet.Packet.codeが3(AccessReject)
  • NPSのログ
    • Reason-Codeが48

48の場合、「ネットワーク ポリシー サーバーで、接続要求が拒否されましたので、接続要求が構成されているネットワーク ポリシーが一致しません」のようです。

なお、ネットワークポリシーがうまく作成されていない場合、そのポリシーがスルーされ、デフォルトのConnections to other access serversポリシーに合致して、接続が拒否されることもありました。

その場合のReason-Codeは65 (ネットワークへのアクセス許可 ネットワークへのアクセス許可 の設定、ユーザー アカウントのダイヤルイン プロパティへのアクセスを拒否]が設定されます) でした。
Access Request Was Denied - MSDN

 

Djangoアプリで入力したユーザのパスワードが違う場合
  • Djangoアプリ
    • pyrad.packet.Packet.codeが3(AccessReject)
  • NPSのログ
    • Reason-Codeが16
      • secretが異なる時と同じ

 

ソースコード

GitHubに上げておきました。
thinkAmi-sandbox/Django_AD_Radius_Sample

 

参考

RADIUSについては以下の本がとても参考になりました。

RADIUS―ユーザ認証セキュリティプロトコル

RADIUS―ユーザ認証セキュリティプロトコル