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

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

## 証明書の準備

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

* [RSA鍵、証明書のファイルフォーマットについて \- Qiita](https://qiita.com/kunichiko/items/12cbccaadcbf41c72735)
* [PKCS \#12 個人情報交換ファイルフォーマットについて \- Qiita](https://qiita.com/kunichiko/items/3e2ec27928a95630a73a)

プライベート認証局の作成と証明書の発行を行っている記事では以下の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 などの設定のことです。これらの設定を適切に行うと証明書の用途を制限することができたり証明書の検証を簡略化することができるようです。サーバー証明書の場合には適切に設定されていないとブラウザに拒否されることがあるようですが、クライアント証明書の場合にはそうでもないようだったので、今回は設定を省略しました。ちゃんと勉強するならば以下のページがよさそうです。

* [B\.3\. Standard X\.509 v3 Certificate Extension Reference \- Red Hat Customer Portal](https://access.redhat.com/documentation/en-us/red_hat_certificate_system/9/html/administration_guide/standard_x.509_v3_certificate_extensions)
* [man 5 x509v3_config](https://www.openssl.org/docs/man1.0.2/man5/x509v3_config.html)
* [openssl \- Creating an x509 v3 user certificate by signing CSR \- Stack Overflow](https://stackoverflow.com/questions/18233835/creating-an-x509-v3-user-certificate-by-signing-csr)

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

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

ほとんど [この記事](https://qiita.com/kunichiko/items/12cbccaadcbf41c72735) のコピペです。やることは以下の通りです。

1. 認証局の秘密鍵の作成
2. 認証局の CSR (Certificate Signing Request) の作成
3. CSR を 1. で作成した秘密鍵で署名して証明書を作る（自己署名証明書）

```bash
# 秘密鍵の作成（本当はビット長などを指定するべきだけどデフォルトで 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) フォーマットで保存する

```bash
# ユーザー名を指定する
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 の組み込み変数](http://nginx.org/en/docs/http/ngx_http_ssl_module.html#variables) を使うのがよさそうでした。

* [nginx 特定のフォルダにクライアント認証 \- パソコン関連メモ](https://blog.goo.ne.jp/nog44/e/c4d6cb1e6d12d23770500b3375f25dc8)
* [SSLクライアント証明書でユーザ認証 \(nginx\) \| Netsphere Laboratories](http://www.nslabs.jp/pki-client-certification-with-nginx.rhtml)

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

* [XAdESにおける発行者名前比較の問題（第4回）](http://blog.livedoor.jp/k_urushima/archives/650689.html)
* [XAdESにおける発行者名前比較の問題（第5回）](http://blog.livedoor.jp/k_urushima/archives/651547.html)

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

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

```bash
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 の挙動については以下のページが参考になります。

* http://agentzh.blogspot.com/2011/03/how-nginx-location-if-works.html
* https://www.nginx.com/resources/wiki/start/topics/depth/ifisevil/

## 試してみる

### /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 で開くと証明書の利用が求められました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154409/f903d981-8ecb-6521-12a0-3a252067a0de.png" width="630px">

## そのほか参考にした記事

* https://deeeet.com/writing/2015/11/10/go-crypto/  
  Go の標準ライブラリの [crypto/x509](https://golang.org/pkg/crypto/x509/) を使って証明書を発行する例です。openssl コマンドを使う場合と比べてインターフェースが整理されているので、認証局の作成や証明書の発行を自動化するなら openssl コマンドを exec するよりも Go の crypto/x509 で作りたいなと思いました。
* https://www.ssltrust.com.au/help/setup-guides/client-certificate-authentication  
  最終的にこの記事とほぼ同じ内容になりました。
* https://toripiyo.hatenablog.com/entry/2017/08/05/140827  
  CRL（証明書失効リスト）を使って証明書を失効＆再有効化する手順が書いてあります。
* http://www2.matsue-ct.ac.jp/home/kanayama/text/nginx/node94.html  
  証明書の作成方法について参考にさせていただきました。
* http://www.nslabs.jp/pki-ca-hierarchy-and-basics.rhtml  
  CA.pl, CA.sh の中で実行されている処理についての記載が参考になりました。keyUsage や extendedKeyUsage について関連する RFC の記載があり便利でした。
* http://liosk.blog103.fc2.com/blog-entry-216.html  
  CA.pl, CA.sh を使う目的についての記載があり、参考にさせていただきました。
* http://aki2o.hatenablog.jp/entry/2015/01/12/223011  
  CA.pl, CA.sh を使って証明書を発行する場合の事例として参考になりました。最終的に「難しくてよく分からないからやめよう！」という判断になったのですが。
* http://d.hatena.ne.jp/entree/20150308/1425828062  
  CA.pl, CA.sh を使わずに証明書を発行する方法の参考になりました。これを見て、最終的に CA.pl, CA.sh を使わずにやろうという気持ちになりました。
* https://qiita.com/mitzi2funk/items/602d9c5377f52cb60e54  
  openssl.cnf で指定する設定の内容について参考にさせていただきました。