Basic認証でBalancing

同じURIにアクセスしても、Basic認証で使用したユーザ名によって、管理用のアプリケーションにProxyしたり、ユーザ用のアプリケーションにProxyしたりする方法を考える。

具体的な例としては、mongo-expressでWeb UIを通してMongoDBのデータを確認したいものの、ログインするBasic認証アカウントによって、書き込み権限を持ったMongoDBユーザを使用して起動しているmongo-expressにつなぐのか、読み込み権限だけを持ったMongoDBを使用して起動しているmongo-expressにつなぐのかを分けたい。

今回使用したミドルウェアのversion

  • mongo-express: 0.47.0
  • Nginx: 1.11.12

mongo-express

Basic認証によるBalancingの話の前に、権限別で複数アカウントのmongo-expressをどのように動かしているのかを説明する。

mongo-expressにはinitスクリプトがないため、簡易的に以下のように作成した。

$ cat /etc/init.d/mongo-express 
#!/bin/bash
#
# chkconfig: - 85 15
# description: mongo-express.

case "$1" in
  start)
        source /etc/profile.d/nvm.sh
        nohup mongo-express -d db_express_test -u r_user  -p PASSWORD1 --port 8081 >/dev/null 2>&1 &
        nohup mongo-express -d db_express_test -u rw_user -p PASSWORD2 --port 8082 >/dev/null 2>&1 &
        ;;
  *)
        echo $"Usage: $0 start"
        RETVAL=3
esac

exit $RETVAL

読み込み権限を持ったMongoDBユーザ(r_user)でMongoDBに接続するmongo-expressを8081ポートで起動し、読み書き両方の権限を持ったMongoDBユーザ(rw_user)でMongoDBに接続するmongo-expressを8082ポートで起動している。

直接http://FQDN:8081/http://FQDN:8082/にアクセスすれば、それぞれで権限を分けることができるが、同じFQDNと同じポートでアクセスさせたい場合、Webサーバでリバースプロキシしてリクエストを割り振る必要がある。

Nginxと$remote_user

Basic認証を行うと、$remote_userにBasic認証で使用したユーザ名が入る。ApacheのREMOTE_USERと同じ。

そのためlocation /内でBasic認証をした後に$remote_userを参照してProxy先を分けるロジックを記載することができる。

location / {
    auth_basic "Restricted";
    auth_basic_user_file /etc/nginx/.htpasswd;
    if ($remote_user = "admin") {
        proxy_pass  http://localhost:8081;
    }
    if ($remote_user != "admin") {
        proxy_pass  http://localhost:8082;
    }
}

Nginxのifをlocation内で書くことはIf Is Evilと言われている。

Directive if has problems when used in location context, in some cases it doesn’t do what you expect but something completely different instead.

今回の記述では問題なく動いているが、何かカスタマイズする際はNginxのlocation内でのifの動きに注意することに加えて、Luaスクリプトで代替するなども検討した方がいい。

2021/02/02 追記: nginx if

If Is Evilではifを使っても問題ないパターンとして以下の2点が挙げられている。

  • return ...;
  • rewrite ... last;

今回のケースで動作確認はしていないが、別件でifを使った際にはこの問題ないパターンで実装した。今回のケースでは以下のように記載すれば動くはず。

location / {
    auth_basic "Restricted";
    auth_basic_user_file /etc/nginx/.htpasswd;
    if ($remote_user = "admin") {
        rewrite ^/(.*) /admin_location_not_exist_in_app/$1 last;
    }
    if ($remote_user != "admin") {
        rewrite ^/(.*) /user_location_not_exist_in_app/$1 last;
    }
}

location /admin_location_not_exist_in_app/ {
    proxy_pass  http://localhost:8081/;
}

location /user_location_not_exist_in_app/ {
    proxy_pass  http://localhost:8082/;
}

ポイントは以下の点

  • rewrite ... last;を使用
  • rewrite ... last;でURIを書き換えて再度locationマッチを行い、適切なproxy先へ繋ぐ
  • proxy_passの末尾に/を入れることで、自分で付与した余分な部分のURIを削除してproxy先へ繋ぐ

※最後のproxy_passの/有無については公式ドキュメントを参照

  • If the proxy_pass directive is specified with a URI, then when a request is passed to the server, the part of a normalized request URI matching the location is replaced by a URI specified in the directive:
  • If proxy_pass is specified without a URI, the request URI is passed to the server in the same form as sent by a client when the original request is processed, or the full normalized request URI is passed when processing the changed URI:

Nginxと$remote_userとifの動作確認

mongo-expressを実際に動かして確認するのはWebブラウザで行うとして、Nginxだけでうまくlocation内のifが動作しているか簡単に確認してみる。Nginxのconfに以下を記載する。

server {
    listen       localhost:8081;
    server_name  localhost;

    location / {
        return 200 "admin app is running on port 8081\n";
    }
}
server {
    listen       localhost:8082;
    server_name  localhost;

    location / {
        return 200 "user app is running on port 8082\n";
    }
}
server {
    listen       localhost:443;
    server_name  localhost;

    ssl                  on;
    ssl_certificate      /etc/pki/tls/certs/server.crt;
    ssl_certificate_key  /etc/pki/tls/private/server.key;
    ssl_session_timeout  5m;
    ssl_protocols  TLSv1.2;
    ssl_ciphers ALL:!ADH:!EXPORT:!SSLv2:RC4+RSA:+HIGH:+MEDIUM:+LOW;
    ssl_prefer_server_ciphers   on;

    location / {
        auth_basic "Restricted";
        auth_basic_user_file /etc/nginx/.htpasswd;
        if ($remote_user = "admin") {
            proxy_pass  http://localhost:8081;
        }
        if ($remote_user != "admin") {
            proxy_pass  http://localhost:8082;
        }
        proxy_redirect http:// https://;
    }
}

この設定でNginxを再起動した後、curlでBasic認証を突破させる。

$ curl -u user:user -k https://localhost/
user app is running on port 8082
$ curl -u admin:admin -k https://localhost/
admin app is running on port 8081