はじめに
スマートフォンやPCから、リモートアクセスVPNで業務システムに接続させたい――という相談はよくあります。利用者が十数名であればFortiGateのローカルユーザーで管理しても困りませんが、数百名規模になると事情が変わります。機種ごとにローカルユーザーの登録数には上限がありますし、入退職のたびに管理者がFortiGateの設定を触る運用も現実的ではありません。
かといってクライアント証明書による認証にすると、利用者1人ごとに証明書を発行・配布・失効させるPKIの運用が必要になります。
本記事では、FortiGateのIKEv2ダイヤルアップVPNの認証をFreeRADIUSに任せ、アカウント情報をMySQLのテーブル1つで管理する構成を、社内の検証環境で実際に組み立てながら整理します。ゴールは次の3点です。
- OS標準のVPNクライアント(今回はiOSで確認)で、追加アプリなしに接続できること
- VPNアカウントの登録・停止・パスワード変更が、DBの更新だけで即時に反映されること
- 業務システム宛ての通信だけをVPNに通す(スプリットトンネル)こと
結論から言うと、「FortiGate(IKEv2+EAP)→ FreeRADIUS(EAP-MSCHAPv2)→ MySQL(NTハッシュ)」の組み合わせで実現できました。ただし途中にいくつか分かりにくい落とし穴があったので、それも含めて紹介します。
全体像
検証環境は次のとおりです。
- FortiGate 60F(リモートアクセスVPNの終端)
- AlmaLinux 9のサーバー1台(FreeRADIUS 3.0.27、MySQL 8.4 Community Server)
- クライアント: iPhone(iOS標準のIKEv2クライアント)、モバイル回線から接続
認証の流れは次のようになります。
iPhone –(IKEv2 / EAP-MSCHAPv2)–> FortiGate –(RADIUS)–> FreeRADIUS –(SQL)–> MySQL
FortiGateはサーバー証明書で自分の身元を示し、利用者とのEAPのやり取りをそのままFreeRADIUSへ中継します。FreeRADIUSはMySQLからそのユーザーのNTハッシュを取り出し、MSCHAPv2の応答を検算します。FortiGate自身はパスワードを一切持ちません。
なぜLDAPではなく「RADIUS+SQL」なのか
アカウントを一元管理するならLDAP、というのが最初に浮かぶ発想だと思います。しかし、IKEv2のOS標準クライアント(Windows・iOS・Android)が使うEAP-MSCHAPv2とLDAPの組み合わせには注意が必要です。
MSCHAPv2はチャレンジ・レスポンス方式で、認証する側がパスワードのNTハッシュを持っていて、クライアントから届いた応答をそのハッシュで検算します。一方、FortiGateのLDAP連携は平文パスワードでのbindが前提のため、MSCHAPv2の応答をLDAPで検証できません。Fortinetのコミュニティでも、LDAPのユーザーグループをIKEv2のEAPに使うと失敗する事例が報告されています。
そこで検算役としてFreeRADIUSを間に置きます。FreeRADIUSはNTハッシュさえ取り出せれば、保存先がLDAPでもSQLでも構いません。今回はさらに一歩進めて、LDAPを置かずにMySQLのテーブルを直接参照する形にしました。理由は次のとおりです。
- 業務システムがすでにMySQLを持っていれば、社員の登録・停止と同じタイミングでVPN用テーブルを更新するだけで済む
- 管理するサーバー(LDAP)が1つ減る
- RHEL 8/9系ではOpenLDAPのサーバーパッケージが標準リポジトリから外れており(AlmaLinux 9ではEPELから導入可能)、長期保守の面でも避けたかった
なお、FortiClient(7.4.3以降)とFortiOS 7.6系を組み合わせればEAP-TTLSでLDAPに直接つなぐ方法もありますが、クライアントがFortiClientに限定されます。OS標準クライアントで済ませたい今回は対象外としました。
MySQL側の準備
VPN用テーブルを作る
FreeRADIUSには標準のスキーマ(radcheckなど)が付属しますが、属性名と値を1行ずつ持つ汎用的な形なので、アプリから扱うには少し不便です。今回は「ユーザー名・NTハッシュ・有効フラグ」だけを持つ専用テーブルを用意しました。
CREATE DATABASE radius; CREATE USER 'radius'@'localhost' IDENTIFIED BY '********'; USE radius; SOURCE /etc/raddb/mods-config/sql/main/mysql/schema.sql; CREATE TABLE vpn_users ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(64) NOT NULL UNIQUE, nt_hash CHAR(32) NOT NULL, enabled TINYINT(1) NOT NULL DEFAULT 1, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); GRANT SELECT ON radius.* TO 'radius'@'localhost'; GRANT INSERT ON radius.radpostauth TO 'radius'@'localhost';
標準スキーマも読み込んでいるのは、認証結果のログ(radpostauth)をそのまま使うためです。FreeRADIUS用のDBユーザーには、参照権限とログ書き込みの権限だけを与えています。
NTハッシュを作る
NTハッシュは、パスワードをUTF-16LEに変換してMD4をかけた値です。FreeRADIUSに付属するsmbencryptで作れます(出力の右側、32桁がNTハッシュです)。
smbencrypt 'パスワード'
アプリ側で作る場合、PHPなら次の1行です。PHPのhash()はMD4を自前で実装しているため、OpenSSL 3の環境でも使えます。
$nt = hash('md4', mb_convert_encoding($password, 'UTF-16LE', 'UTF-8'));
注意点として、NTハッシュはソルトなしのMD4で、漏れればパスワードと同じ価値を持ちます。テーブルのアクセス権を絞るのはもちろん、VPNのパスワードは業務システムのログインパスワードとは別にしておくのが安全です。同じにすると、bcryptなどで守っているログインパスワードの強さが、MD4の弱さまで下がってしまいます。
FreeRADIUSの設定
dnf install freeradius freeradius-mysql freeradius-utils
EL9のfreeradius-mysqlは、MySQL公式ではなくMariaDBのクライアントライブラリ(libmariadb)にリンクされています。MySQL 8.4の標準の認証方式(caching_sha2_password)に対応するプラグインが入っているかを、先に確認しておくと安心です。
ldd /usr/lib64/freeradius/rlm_sql_mysql.so | grep -iE 'maria|mysql' ls /usr/lib64/mariadb/plugin/ | grep caching_sha2
今回の環境ではcaching_sha2_password.soが入っており、同じサーバー上のMySQLにUNIXソケット経由で問題なく接続できました。
sqlモジュール
mods-available/sqlを有効化し、接続先を設定します。
cd /etc/raddb ln -s ../mods-available/sql mods-enabled/sql
主な変更点は次のとおりです。
dialect = "mysql"
driver = "rlm_sql_${dialect}"
mysql {
# tls { ... } のブロックはコメントアウト
warnings = auto
}
server = "localhost"
login = "radius"
password = '********'
radius_db = "radius"
read_groups = no
read_clients = no
初期状態のmysqlセクションには、存在しない証明書を指すtlsブロックが書かれています。同じサーバー上のMySQLにソケットでつなぐ今回は、コメントアウトしておきます。パスワードをシングルクォートで囲んでいるのは、ダブルクォートの中では$や\がFreeRADIUSの変数・エスケープとして解釈されるためです。記号を含むパスワードでハマりやすいポイントです。
独自テーブルを参照させる
mods-config/sql/main/mysql/queries.confのauthorize_check_queryを、独自テーブルからNT-Passwordを返すように書き換えます。FreeRADIUSが期待する「id, username, attribute, value, op」の5列の形に、SELECTで整形するのがポイントです。
authorize_check_query = "\
SELECT id, username, 'NT-Password' AS attribute, nt_hash AS value, ':=' AS op \
FROM vpn_users \
WHERE username = '%{SQL-User-Name}' AND enabled = 1"
WHERE句にenabled = 1を入れておくことで、フラグを0にするだけでアカウントを止められます。
EAPとクライアントの登録
mods-available/eapのdefault_eap_typeをmschapv2に変更し、clients.confにFortiGateを登録します。
client fortigate {
ipaddr = 192.168.0.1
secret = <長い共有シークレット>
}
radiusd -Xで起動すると、BlastRADIUS(RADIUSプロトコルの脆弱性)対策としてrequire_message_authenticatorの設定を促す警告が出ます。今回のFortiGateからの要求にはMessage-Authenticator属性が付いていることをデバッグ出力で確認できたので、動作確認が済んだらクライアント定義にrequire_message_authenticator = yesを追加しておくのがおすすめです。
RADIUSのポート(UDP 1812)は、FortiGateからだけ受け付けるようにしておきます。
firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="192.168.0.1/32" port port="1812" protocol="udp" accept' firewall-cmd --reload
段階的に動作確認する
VPNの設定まで一気に組んでしまうと、つながらなかったときにどこが悪いのか分からなくなります。FreeRADIUS単体、FortiGateからの問い合わせ、実機からの接続、の順に確認していきました。
FreeRADIUS単体での確認
radiusd -X # デバッグモードで起動(別の端末で以下を実行) radtest -t mschap testuser 'パスワード' 127.0.0.1 0 testing123
Access-Acceptが返れば、MySQL→NTハッシュ→MS-CHAPの流れは動いています。続けてenabledを0にすると、FreeRADIUSを再起動しなくても、次の認証からAccess-Rejectに変わりました。1に戻せば、すぐにまた通ります。DBの更新がそのまま即時反映されるのが、この構成のうれしいところです。
UPDATE vpn_users SET enabled = 0 WHERE username = 'testuser';
FortiGateからの問い合わせ
config user radius
edit "FR-TEST"
set server "192.168.0.15"
set secret <共有シークレット>
next
end
config user group
edit "VPN-RADIUS-TEST"
set member "FR-TEST"
next
end
diagnose test authserver radius FR-TEST mschap2 testuser 'パスワード'
「succeeded」と出れば、FortiGate→FreeRADIUS→MySQLの経路でMSCHAPv2の認証が通っています。VPNを組む前にここを確かめておくと、あとで失敗したときの切り分けがぐっと楽になります。
IKEv2ダイヤルアップVPNを組む
サーバー証明書はLet’s Encryptで
OS標準クライアントは、FortiGateのサーバー証明書が信頼できるCAのもので、接続先の名前と一致していないと接続しません。自己署名の証明書にすると、利用者の端末それぞれにCA証明書を入れてもらう必要が出てきます。
FortiOS 7.0以降は、GUIの証明書作成で「Let’s Encrypt」を選べば、ACMEで自動発行・自動更新してくれます。前提は、FQDNがFortiGateのWAN側のIPを向いていることと、外からそのIPの80番に届くことです。同じIPの80番をVIPで内側のサーバーに転送していると、発行に失敗します。
phase1・phase2・ポリシー
以下はFQDNをvpn.example.jp、業務サーバーを192.168.0.15とした例です。
config firewall address
edit "app-server"
set subnet 192.168.0.15 255.255.255.255
next
edit "IKEv2-POOL"
set type iprange
set start-ip 10.10.200.1
set end-ip 10.10.200.20
next
end
config vpn ipsec phase1-interface
edit "ikev2-ra"
set type dynamic
set interface "wan1"
set ike-version 2
set authmethod signature
set peertype any
set net-device disable
set mode-cfg enable
set proposal aes256-sha256 aes128-sha256
set dhgrp 14 19
set certificate "vpn.example.jp"
set eap enable
set eap-identity send-request
set authusrgrp "VPN-RADIUS-TEST"
set ipv4-start-ip 10.10.200.1
set ipv4-end-ip 10.10.200.20
set ipv4-netmask 255.255.255.0
set dpd on-idle
set idle-timeout enable
set idle-timeoutinterval 10
next
end
config vpn ipsec phase2-interface
edit "ikev2-ra-p2"
set phase1name "ikev2-ra"
set proposal aes256-sha256 aes128-sha256
set dhgrp 14 19
set src-subnet 192.168.0.15 255.255.255.255
next
end
config firewall policy
edit 0
set name "ikev2-ra-to-app"
set srcintf "ikev2-ra"
set dstintf "internal1"
set srcaddr "IKEv2-POOL"
set dstaddr "app-server"
set action accept
set schedule "always"
set service "ALL"
next
end
同じWANインターフェースに既存のIKEv1ダイヤルアップ(L2TP/IPsecなど)があっても、IKEのバージョンで振り分けられるため、今回の環境では問題なく共存できました。
iOS側の設定
「設定」→「一般」→「VPNとデバイス管理」→「VPN」→「VPN構成を追加」で、タイプに「IKEv2」を選び、サーバとリモートIDにFQDN、ユーザ認証に「ユーザ名」を選んでユーザ名とパスワードを入力するだけです。構成プロファイルや証明書のインストールは必要ありません。
接続時のFreeRADIUSのデバッグ出力を追うと、EAP-Identity → MSCHAPv2のチャレンジ → 応答の検算 → EAP Success、という流れがそのまま確認できます(抜粋)。
eap: EAP-Identity reply, returning 'ok' so we can short-circuit the rest of authorize eap_mschapv2: Issuing Challenge sql: User found in radcheck table mschap: Client is using MS-CHAPv2 eap_mschapv2: MSCHAP Success eap: Sending EAP Success (code 3) ID 84 length 4 Sent Access-Accept Id 9 from 192.168.0.15:1812 to 192.168.0.1:1567
MSCHAPv2は単体では弱い方式として知られていますが、IKEv2ではEAPのやり取り全体がIKE SAで暗号化され、FortiGateもサーバー証明書で身元を示すため、通信路上で応答を盗み見られる心配はありません。
陥りがちな罠
構築中に実際にハマったポイントを紹介します。
罠1: IPv6を無効にしたサーバーでradiusdが起動しない
sites-enabled/defaultには、IPv4(ipaddr = *)とIPv6(ipv6addr = ::)の両方で待ち受けるlistenブロックが書かれています。IPv6を無効にしているサーバーでは「Address family not supported by protocol」で起動に失敗するので、ipv6addrのlistenブロック(authとacctの2つ)をコメントアウトします。
罠2: 独自テーブルなのに「User not found in radcheck table」と出る
独自テーブルに条件に合う行がないとき、デバッグ出力にはradcheckと表示されます。テーブル名の設定ミスではなく、単に行がない(ユーザー名に空白が混じっている、enabledの値が違う、別のDBにINSERTしていた、など)ことを疑ってください。
罠3: authmethod signatureを入れるとpeertypeが元に戻る
phase1でpeertype anyを先に入れ、あとからauthmethod signatureを入れると、peertypeが初期値のpeerに戻り、「Attribute ‘peer’ MUST be set.」で保存に失敗します。authmethodを先、peertypeを後の順で入れる必要があります。
罠4: split-includeを入れてもフルトンネルになる
当初はphase1のipv4-split-includeで業務サーバーだけを渡していましたが、最初の接続ではスマートフォンから外部のWebサイトが見られなくなりました。トンネルの状態を見ると、FortiGate側のセレクタが全アドレスになっています。
diagnose vpn tunnel list name ikev2-ra_0 (抜粋) src: 0:0.0.0.0-255.255.255.255:0 dst: 0:10.10.200.1-10.10.200.1:0
つまり「全宛先をVPNに通す」取り決めになっており、インターネット宛ての通信もトンネルに入って、FortiGateのポリシーで捨てられていました。phase2でFortiGate側のサブネット(src-subnet)を業務サーバーだけに絞ると、セレクタが絞り込まれ、iOSでは業務サーバーとインターネットの両方が見られる状態になりました。前掲のphase2の設定は、この修正を反映したものです。
src: 0:192.168.0.15-192.168.0.15:0
VPNに通したい宛先が複数ある場合は、アドレスグループを作ってphase2のセレクタ(src-addr-typeをnameにしてsrc-nameで指定)に使います。WindowsのOS標準クライアントは経路の扱いが異なり、Add-VpnConnectionRouteでの経路追加が必要になることがあるので、端末OSごとの確認は欠かせません。
罠5: デバッグ出力には秘密情報がそのまま出る
radiusd -Xの出力にはNT-Passwordの値が、diagnose vpn tunnel listの出力にはESPの暗号鍵が、そのまま表示されます。検証用アカウントなら問題ありませんが、本番環境の出力をチケットやチャットに貼るときは必ず伏せてください。本番のradiusdを常時デバッグモードで動かさないことも大切です。
まとめ
今回組み立てた仕組みを整理すると、次のようになります。
- 接続 → FortiGateのIKEv2ダイヤルアップVPN(OS標準クライアント、Let’s Encryptのサーバー証明書)
- 認証 → FreeRADIUSがEAP-MSCHAPv2を処理(FortiGateはEAPを中継するだけ)
- アカウント管理 → MySQLの専用テーブル(NTハッシュと有効フラグ)。停止・変更は即時反映
- 通信範囲 → phase2のセレクタで、業務サーバー宛てだけをVPNに通す
- 認証ログ → radpostauthに、ユーザー名・結果・日時を記録
FortiGateのローカルユーザー数の上限や、クライアント証明書の個別配布といった制約を避けつつ、アカウント管理を業務システム側に寄せられるのがこの構成の強みです。
今回はiOSでの動作確認までですが、AndroidやWindowsの標準クライアント、無通信時の自動切断(idle-timeout)、接続中にアカウントを停止した場合の挙動などは、本番導入の前に一つずつ確認しておくことをおすすめします。

