エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

認証があるSPAのリソースはどうしたらいいのか、わからん

こんにちは、エムスリーエンジニアリンググループの福林 (@fukubaya) です。 本記事はエムスリー Advent Calendar 2022 の6日目の記事です。 前日は @yuba による 令和最新版エンジニアのリーダーシップ論 でした。

m3のほとんどのサービスは認証が必要なサービスですが、その中でSPAのリソースをどのように公開するかは、いつも決まった基準や正解がなくて、モヤモヤしていました。 以前作ったDocpediaでは、SpringBootの機能を使って直接リソースを提供するような構成にしていました。

今回は、SpringBootでない環境でもSPAのリソースの提供をどうすればいいか検討して構築したので紹介します。

福岡国際会議場は、福岡県福岡市博多区の国際会議場。本文には特に関係ありません。

m3ラウンジ

この半年くらい m3ラウンジ と呼ばれるサービスを開発しています。8月末くらいにリリースされて、今も開発を続けています。 一言で言うとFacebookの医師版のようなサービスで、実名でさまざまな話題に関してやりとりしてもらうサービスです。

今回は、このサービスを構築するにあたって、認証を必要とするSPAをどのように構成するのがよいかを検討して、今回採用した構成を紹介します。

m3ラウンジ(開発環境)

サービスの構成

今回のサービスの構成です。goで実装されたAPIサービスがAuroraやDynamoDBや社内の認証APIとやりとりしてサービスを提供します。 特に変わった構成ではなく、SPAのリソースをどこに置いたらいいのか? が今回の課題です。

サービスの構成図

TLSの終端もALBでやっているので、nginxは置かなくてもいいんじゃないか、という意見もありますが、最近のセキュリティ対策としてHSTSなど必要なヘッダを全リクエストに漏れなく付与するなど、対処しなければいけない要件が多いので、単純に本体のgoコンテナにプロキシするだけであってもnginxのようなwebサーバを挟むことは必要だと思います。

SPAのリソースはどのように公開したらよいか?

認証が必要なサービスであっても、SPAのリソースだけを取得しても秘密情報が漏れる訳でもないし、何かの攻撃ができる訳でもない(攻撃のヒントを得ることはできるかもしれない)ので、SPAのリソースは認証なしでURLを知っていれば誰でも取得できる、という解もあると思います。

ただ、本来は参照できないはずのファイルは参照できないようになっていた方が、セキュリティ観点でも安心度が増しますし、正当なユーザ以外のリクエストは拒否することはサービスの負荷を下げ、安定性を向上させる上でも必要だと思います。

CDN

最初に思いつくのはS3+CloudFrontのようなCDNですが、認証したユーザにだけリソースを取得可能にするためには工夫が必要です。 Lambda@Edge のようなCDNのエッジサービスで認証済みかどうかを判定するか (あまり調べてられていないので実現可能か分からない)、CloudFrontであれば署名付きURLや署名付きcookieを使って、認証済みユーザだけがアクセスできる仕組みを用意する必要があります。

今回のサービスでは、SPAのリソース取得のために毎回署名付きURLもしくはcookieを発行するのは面倒なのと、サービスリリース時に、APIコンテナのDockerイメージとは別に、SPAのリソースのS3へのデプロイが必要で、デプロイ先でケアしなければならないサービスが増えるのが嫌で採用しませんでした。

一方でm3ラウンジではユーザが画像を投稿できるので、ユーザが投稿した画像の表示には期限を設定した署名付きURLを発行して、認証済みユーザだけが画像を表示できるようにしています。

go:embed

go1.16から入った go:embed ではgoのバイナリにファイルを埋め込むことができ、これを使えばgo側でSPAリソースの提供まで制御できます。

future-architect.github.io

正直 go:embed をあとから知ったので、はじめから知っていれば採用していたかもしれませんが、インフラ管理の点では埋め込まないでよかったかなと思います。

SPAなどの静的ファイルの提供をgoが実行されるdockerが受け持つと、goのdockerコンテナの本来の役割であるAPIサービス以外にも、CPUやメモリを使用されることになり、APIサービス自体のCPUやメモリの使用状況を正確に把握することができなくなると思いました。 また、静的ファイルの提供部分でのエラーや不具合で、本来のAPIサービスの運用に影響が出てしまうこともあるので、役割の分担の意味でもgoのコンテナと分離したのはよかったと思います。

nginx auth_request

今回採用したのは、nginx の ngx_http_auth_request_module モジュールです。

nginx.org

このモジュールでは、nginxが事前に設定したpathに対して認証問い合わせを実施し、認証が通れば要求されたリソースを返す、という動作が実現できます。 これにより、認証でアクセス制限は必要だけど、リソースの提供自体は認証とは別のコンテナで提供でき、役割の分担が実現できます。

nginxの設定と認証実装

実際に採用したnginxの設定と認証の実装を紹介していきます。

SPAリソースファイルはdockerコンテナに埋める

SPAのリソースなどの静的ファイルはnginxのdockerコンテナに埋めてしまいます。 そのためマルチステージビルドで、まずSPAのリソースをビルドして、その後 /var/www/html/ に生成されたファイルをCOPYします。

# vue ビルド
FROM node:12.22.7 AS builder

COPY ./vue /workspace
WORKDIR /workspace

RUN yarn install && yarn run build

# 本体イメージ(ARM64)
FROM nginx:1.23.2-alpine@sha256:49301a44d4c1cbb90cf82a103ab8f6edf1c3d2ce78c26558c66ca62fcb268000 AS arm64

RUN apk add tzdata
ENV TZ=Asia/Tokyo

COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf

# ビルドしたSPAリソースをコピー
COPY --from=builder /workspace/dist /var/www/html/

CMD ["nginx", "-g", "daemon off;"]

設定

まず、goのサーバをupstreamとして設定します。

upstream appsrv {
  # goのコンテナ
  server ${APP_SERVER}:8080;
}

/var/www/html/ 配下の (css|js|img)/(.*)$ に対してのアクセスは、goの /auth に問い合わせるように設定します。 /auth に問い合わせた結果、2xxが返れば要求されたリソースは提供されますが、401か403が返された場合はリソースの提供は拒否されます。 goはcookieなどからセッション情報などを得て、提供可否を判断します。

server {
...
  location ~ ^/(css|js|img)/(.*)$ {
    gzip_static always;
    gunzip on;
    root /var/www/html;
    auth_request /auth;
  }
...
}

SPAでは、ルーティングはフロントで実施され、/ であっても /topic/{id} であっても、全て同じエントリーポイントとなるHTML (index.html)を返したいので、他のパスは全て index.html を返します。

server {
...
  location / {
    gzip_static always;
    gunzip on;
    root /var/www/html;
    auth_request /auth;
    try_files $uri /index.html;
  }
...
}

認証における注意点

実装に特別な配慮は必要なく、cookieやヘッダなどから必要な情報を得て、可否を返せばよいだけなのですが、注意が必要です。

今回のサービスでは、 /auth だけでなくAPIの他のpathも認証は認証・認可middlewareが実施していて、/auth 自体は何もしないで応答を返す実装になっていました。

func setRoute(r *chi.Mux) {
...
    // nginxからの認証確認
    r.Group(func(r chi.Router) {
        // 認証・認可middleware
        r.Use(authNauthZ)
        r.Get("/auth", func(w http.ResponseWriter, r *http.Request) {})
    })
...
}

ただ、この認証・認可middlewareは、未認証状態の場合には、ログインページにリダイレクト(302)していたり、認証処理の途中でエラーが発生したら503を返す実装になっていました。

middelwareの実装としてはこの実装で特に問題はなかったのですが、今回のnginxのモジュールでは、この仕様が問題となりました。

モジュールの説明にも書いてあるように、このモジュールは2xx, 401, 403のどれかが返る想定なので、3xxを返してもnginx上でエラーとなり5xxを返します。 未認証状態で index.html を取得しようとして、5xxエラーが返ってしまうことがありました(しかもgo側のエラーでないので、goのログには残らないので追跡しづらかった)。

このため、認証・認可middlewareか /auth のhandlerの処理で、/auth へのアクセスの時だけ、エラーやリダイレクトが必要な場合でも 401 を返すように修正が必要です。 これにより nginx からの問い合わせに関して 2xx, 401, 403 以外が返ることはなくなりました。

       // nginx からの認証確認には401を返す
        if strings.HasPrefix(r.RequestURI, "/auth") {
            http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
            return
        }

まとめ

  • 認証があるSPAサイトで、SPAのリソース提供にnginxの ngx_http_auth_request_module モジュールを使用した。
  • ただし、nginxに返すステータスコードに気をつけないと想定外のエラーになってしまう。

We are hiring!

今回紹介したm3ラウンジを含め、m3の多様なサービスを一緒に開発してくれる仲間を募集中です。お気軽にお問い合わせください。

jobs.m3.com