クライアント証明書でディレクトリごとのアクセスを制限する (nginx)


HTTPS 経由でのアクセスの制限をパスワードよりも強固な方法でやりたいということがあり、nginx でクライアント証明書を使ったアクセス制限をする方法について調べました。

証明書の準備

ここが一番難しいです。私は openssl コマンドに詳しくなかったので、最初に以下の記事で必要なファイルの扱い方について勉強しました。

プライベート認証局の作成と証明書の発行を行っている記事では以下の3つを扱っていることが多いのですが、今回はスキップしました。

  • CA.pl, CA.sh または openssl ca コマンドの利用
  • X509 V3 certificate extension の設定
  • CRL(証明書失効リスト)の管理

CA.pl, CA.sh または openssl ca コマンドの利用については、これらのコマンドを使うと認証局まわりの作業を簡単に行うことができます。一方で、openssl コマンドの理解が浅い場合には生成されるファイルの関係が理解できず混乱すると感じたので、今回は openssl コマンドをそのまま使いました。

X509 V3 certificate extension の設定というのは basicConstraints, keyUsage, extendedKeyUsage, subjectKeyIdentifier, authorityKeyIdentifier などの設定のことです。これらの設定を適切に行うと証明書の用途を制限することができたり証明書の検証を簡略化することができるようです。サーバー証明書の場合には適切に設定されていないとブラウザに拒否されることがあるようですが、クライアント証明書の場合にはそうでもないようだったので、今回は設定を省略しました。ちゃんと勉強するならば以下のページがよさそうです。

CRL の管理を行っておくと発行済みの証明書を失効することができるようになりますが、今回はアクセス可能な証明書を指定する仕組みを別のところで入れるので不要と判断しました。

プライベート認証局の作成

ほとんど この記事 のコピペです。やることは以下の通りです。

  1. 認証局の秘密鍵の作成
  2. 認証局の CSR (Certificate Signing Request) の作成
  3. CSR を 1. で作成した秘密鍵で署名して証明書を作る(自己署名証明書)
# 秘密鍵の作成(本当はビット長などを指定するべきだけどデフォルトで 2048 になっていたので指定しなかった)
openssl genrsa > ca-private.pem

# DN(Distinguished Name, 識別名)を指定する。
# ちゃんとやるなら '/C=JP/ST=Tokyo/O=Eagle Jump Co., Ltd./CN=example.com' のように書く
SUBJECT='/'

# 秘密鍵から取り出した公開鍵 + DN から CSR を作成する
openssl req -new -key ca-private.pem -subj "${SUBJECT}" > ca-request.csr

# 自己署名証明書を作成する
openssl x509 -req -signkey ca-private.pem -days 3650 < ca-request.csr > ca-cert.crt

# 証明書の内容を確認する
# 特に Issuer, Validity, Subject に注意する
openssl x509 -text -noout < ca-cert.crt

クライアント証明書の作成

先ほども書きましたが X509 V3 certificate extension の設定は省略しています。やることは以下の通りです。

  1. ユーザーごとに秘密鍵を作成
  2. ユーザーごとに CSR を作成
  3. CSR をプライベート認証局の秘密鍵で署名して証明書を作る(クライアント証明書)
  4. 証明書と秘密鍵を PKCS #12 (.p12, .pfx) フォーマットで保存する
# ユーザー名を指定する
USER=
mkdir ${USER}

# 秘密鍵を作成する
openssl genrsa > ${USER}/private.pem

# DN(Distinguished Name, 識別名)を指定する
# CN (Common Name) にはユーザー名を指定しておくとよいかもしれない
# ちゃんとやるなら '/C=JP/ST=Tokyo/O=Eagle Jump Co., Ltd./CN=Aoba Suzukaze' のように書く
SUBJECT="/CN=${USER}"

# CSR の作成
openssl req -new -key ${USER}/private.pem -subj "${SUBJECT}" > ${USER}/request.csr

# 証明書を作成する
# ca-cert.crt と同じ場所に ca-cert.srl というファイルができるので注意(man 1 x509 の -CAserial を参照)
# 有効期限はデフォルトで30日しかないので適当に伸ばす
openssl x509 -req -CA ca-cert.crt -CAkey ca-private.pem -CAcreateserial -days 3650 < ${USER}/request.csr > ${USER}/cert.crt

# 証明書の内容を確認する
# 特に Issuer, Validity, Subject に注意する
openssl x509 -text -noout < ${USER}/cert.crt

# 証明書と秘密鍵を PKCS #12 (.p12, .pfx) フォーマットで保存する
# パスワードの入力が必要
openssl pkcs12 -export -inkey ${USER}/private.pem < ${USER}/cert.crt > ${USER}/cert.pfx

クライアント証明書をもとにユーザーを識別する

サーバーの接続全体にクライアント証明書での認証を要求するには ssl_verify_client on; という設定だけでよいのですが、今回はディレクトリごとにアクセス権の設定を行いたかったため、もう少し複雑な方法をとりました。

以下の記事を見たところ、アクセスしているユーザーを識別するには ngx_http_ssl_module の組み込み変数 を使うのがよさそうでした。

クライアント証明書を発行するときに CN (Common Name) の項目にユーザー名を書いておけば $ssl_client_s_dn という変数から CN を取ってきて利用できそうな気がしてくるのですが、残念ながら RFC 2253 をパースして比較するのは簡単ではなさそうでした。

ほかに使えそうな変数は $ssl_client_fingerprint$ssl_client_serial です。それぞれ証明書の SHA-1 fingerprint と証明書のシリアル番号を表しますが、今回はシリアル番号を使ってみました。理由は fingerprint を表記するときの : の有無を考慮するのが面倒そうだったからです。

クライアント証明書のシリアル番号は以下のように openssl コマンドで取得可能です。

openssl x509 -serial -noout < ${USER}/cert.crt

このシリアル番号をもとにディレクトリごとのアクセス権を設定する例が以下になります。

server {
  listen 443 default_server ssl;

  root /var/www/html;
  autoindex on;
  autoindex_exact_size off;

  ssl_certificate ...;
  ssl_certificate_key ...;
  ssl_verify_client optional;
  ssl_client_certificate /etc/nginx/ssl/ca-cert.crt;

  location / {
  }

  location /private {
    if ($ssl_client_verify != SUCCESS) {
      return 403;
    }

    location /private/alice {
      if ($ssl_client_verify != SUCCESS) {
        return 403;
      }

      if ($ssl_client_serial !~* ^(C63D54FEC58EA530)$) {
        return 403;
      }
    }

    location /private/alice-and-bob {
      if ($ssl_client_verify != SUCCESS) {
        return 403;
      }

      if ($ssl_client_serial !~* ^(C63D54FEC58EA530|C63D54FEC58EA531)$) {
        return 403;
      }
    }

    location /private/common {
      if ($ssl_client_verify != SUCCESS) {
        return 403;
      }
    }
  }
}

if ($ssl_client_verify != SUCCESS) の部分を共通化できるのではないかと思うかもしれませんが、nginx の if の不思議な挙動により想定外の結果になりがちです(もっとよい書き方があれば教えてください)。nginx の if の挙動については以下のページが参考になります。

試してみる

/hello.txt

証明書なしでもアクセスできました。

$ curl -I -k https://localhost/hello.txt
HTTP/1.1 200 OK
Server: nginx/1.10.3 (Ubuntu)
Date: Sat, 01 Jun 2019 16:52:45 GMT
Content-Type: text/plain
Content-Length: 7
Last-Modified: Sat, 01 Jun 2019 15:55:48 GMT
Connection: keep-alive
ETag: "5cf2a004-7"
Accept-Ranges: bytes

$

/private/hello.txt

証明書なしだとエラーになりました。

$ curl -I -k https://localhost/private/hello.txt
HTTP/1.1 403 Forbidden
Server: nginx/1.10.3 (Ubuntu)
Date: Sat, 01 Jun 2019 16:53:43 GMT
Content-Type: text/html
Content-Length: 178
Connection: keep-alive

$

作成したプライベート認証局で署名した正当な証明書だとアクセスできます。

$ curl -I --key bob/private.pem --cert bob/cert.crt -k https://localhost/private/hello.txt
HTTP/1.1 200 OK
Server: nginx/1.10.3 (Ubuntu)
Date: Sat, 01 Jun 2019 16:56:04 GMT
Content-Type: text/plain
Content-Length: 8
Last-Modified: Sat, 01 Jun 2019 16:54:58 GMT
Connection: keep-alive
ETag: "5cf2ade2-8"
Accept-Ranges: bytes

$

作成したプライベート認証局以外で署名した適当な証明書だとエラーになります。

$ curl -I --key dummy/private.pem --cert dummy/cert.crt -k https://localhost/private/hello.txt
HTTP/1.1 403 Forbidden
Server: nginx/1.10.3 (Ubuntu)
Date: Sat, 01 Jun 2019 16:57:49 GMT
Content-Type: text/html
Content-Length: 178
Connection: keep-alive

$

/private/alice

alice の証明書(シリアル番号 C63D54FEC58EA530)ではアクセスできます。

$ openssl x509 -serial -noout < alice/cert.crt
serial=C63D54FEC58EA530
$ curl -I --key alice/private.pem --cert alice/cert.crt -k https://localhost/private/alice/hello.txt
HTTP/1.1 200 OK
Server: nginx/1.10.3 (Ubuntu)
Date: Sat, 01 Jun 2019 17:01:45 GMT
Content-Type: text/plain
Content-Length: 16
Last-Modified: Fri, 31 May 2019 17:04:00 GMT
Connection: keep-alive
ETag: "5cf15e80-10"
Accept-Ranges: bytes

$

bob の証明書(シリアル番号 C63D54FEC58EA531)ではアクセスできません。

$ openssl x509 -serial -noout < bob/cert.crt
serial=C63D54FEC58EA531
$ curl -I --key bob/private.pem --cert bob/cert.crt -k https://localhost/private/alice/hello.txt
HTTP/1.1 403 Forbidden
Server: nginx/1.10.3 (Ubuntu)
Date: Sat, 01 Jun 2019 17:02:48 GMT
Content-Type: text/html
Content-Length: 178
Connection: keep-alive

$

ブラウザでも試してみる

クライアント証明書の作成時に一緒に作成した .pfx ファイルをインポートして Chrome で開くと証明書の利用が求められました。

そのほか参考にした記事