📝

Tomcat サーバでFilterを使ってみて、Spring Security を雑に理解する

に公開

はじめに

こんにちは、Cloud Ace の許です。
とある日に Spring Security を勉強したいなと、色々な記事を読んでいました。
その中で、よく出てくるのがこの図でした。

引用元: Spring Security Reference

この図に記載されている"Security Filter Chain"を調べてみると、Java Servlet API の Filter という仕組みを使っていることがわかりました。
そこで、今回は Tomcat サーバを作成してみて、実際に Filter を実装することで、Spring Security がどのように動いているのかをざっくり理解してみたいと思います。

Filter とは

Servlet API の Filter とは、本来の処理の前後に、事前に定義した共通処理を挟むことができる仕組みのことです。
例えば、1 と言う文字列を返す API があったとします。
この API に "フィルター"と言う文字列を挟む Filter を実装すると、API のレスポンスは "フィルター1" という文字列になります。

Filter を使うことで、API の実装を変更することなく、共通処理を挟むことができるため、認証やログ出力などの共通処理を実装するのに便利です。

事前に準備するもの

  • IntelliJ IDEA
  • Postman
  • Tomcat 11.0.2

手順のざっくり解説

  1. Tomcat サーバ立ち上げ
  2. API を作成
  3. Filter を実装して、簡単な処理を追加
  4. Filter に認証・認可を実装

Tomcat サーバ立ち上げ

  1. まず、Tomcat の公式ページから Tomcat をインストールします。
    1. 画面左のメニューから Tomcat11 をクリック
    2. 遷移先の画面にて、tar.gz を選択し、ダウンロード
    3. ダウンロードしたファイルを展開
    4. 展開したファイルを任意の場所に配置して完了(私は /Documents/Java/ に配置しました)
  2. Servlet プロジェクトを IntelliJ IDEA で作成します
    1. IntelliJ IDEA を起動
    2. [新規プロジェクト] をクリック
    3. 遷移先の画面左のメニューから、[Jakarta EE] を選択
    4. 以下の内容を記入していく
      • [名前]は好きなプロジェクト名(英字で記入)
      • [場所]は作成したい場所の Path を入力。git リポジトリは作成しないでいい
      • [テンプレート]から[Web アプリケーション]を選択
      • [アプリケーション]の右側の[新規]で、[Tomcat サーバ]を選択。開いた画面に、先ほどインストールした Tomcat の Path を入力
      • [言語]は Java
      • [ビルドシステム]は Maven
      • [グループ]には任意の名前を入力
      • [アーティファクト]はスルーで問題なし
      • [JDK]は好きな JDK ディストリビューションを選択
    5. 次に開く画面で、[Eclipse Jersey Server]と [Eclipse Jersey Client]を選択
    6. 作成されたプロジェクトで、右上の再生マークをクリックし、Tomcat サーバを立ち上げる
    7. デフォルトだと http://localhost:8080/demo_war_exploded/ で以下の画面が表示されます

API を作成

  1. src 直下の HelloServletSampleTest にリファクタリングします
  2. 以下のコードに書き換えます
    import java.io.*;
    
    import jakarta.servlet.ServletException;
    import jakarta.servlet.http.*;
    import jakarta.servlet.annotation.*;
    
    @WebServlet(name = "helloServlet", value = "/sample-test")
    public class SampleTest extends HttpServlet {
        private String message;
    
        public void init() {
            message = "Hello World!";
        }
    
        public SampleTest() {
            super();
        }
    
        public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
    
    //        レスポンスで"サンプル"という文字列を返す
            response.getWriter().write("サンプル");
        }
    
        public void destroy() {
        }
    }
    
  3. 起動後、Postman を用いて http://localhost:8080/servlet_api_war_exploded/sample-test に GET リクエストを送信する
  4. レスポンスで サンプル という文字列が返却されれば API の作成が成功

Filter を実装して、簡単な処理を追加

ここからは、実際に超簡単な Filter を実装してみます。
Filter の実装には、Filter 用のクラスと、web.xml に Path などの設定を記述する必要があります。

  1. SampleTest と同じフォルダに FilterTest を作成する

  2. FilterTest に以下のコードを記述
    この実装では、Filterでやりたい処理を記述します。(この場合は、レスポンスで "フィルター" という文字列を返す)

    import jakarta.servlet.*;
    
    import java.io.*;
    
    
    public class FilterTest implements Filter {
        public void doFilter(ServletRequest request, ServletResponse response,
                            FilterChain filterChain){
    
            try{
                
                response.getWriter().write("フィルター\n");
                filterChain.doFilter(request, response);
            }catch (ServletException se){
            }catch (IOException e){
            }
        }
    
    }
    
  3. src/main/webapp/WEB-INF/web.xml に以下の内容を追記します。クラス名は自分が検証で利用している名前になっているので、適宜変更してください。
    <filter> タグで Filter のクラス名を指定し、<filter-mapping> タグでどの URL に Filter を適用するかを指定します。

    <filter>
            <filter-name>filetertest</filter-name>
            <filter-class>org.example.servletapi.FilterTest</filter-class>
        </filter>
        <filter-mapping>
            <filter-name>filetertest</filter-name>
            <url-pattern>/*</url-pattern>
        </filter-mapping>
    
        <servlet>
            <servlet-name>sampletest</servlet-name>
            <servlet-class>org.example.servletapi.SampleTest</servlet-class>
        </servlet>
        <servlet-mapping>
            <servlet-name>sampletest</servlet-name>
            <url-pattern>/sampletest</url-pattern>
        </servlet-mapping>
    
  4. Tomcat サーバを再起動し、再度 http://localhost:8080/servlet_api_war_exploded/sample-test に GET リクエストを送信する

  5. フィルター\nサンプルという文字列が返却されれば完成

文字列の表示順からも分かるとおり、処理は FilterTest を通った後、SampleTest で処理を行いレスポンスを返却しています。
なので、Filter に認証を実装すれば、すべてのリクエストが Filter を通り認証がなされるようになります。(と自分は理解した)

Filter に認証・認可を実装

ここからは超シンプルな認証・認可を実装していきます。

  1. SampleTest と同じフォルダに UserInfo を作成します
  2. UserInfo に以下のコードを記述します
    public class UserInfo {
    
        public String userId;
    
        public String password;
    
        public String[] roles;
    
        public UserInfo() {
            this.userId = null;
            this.password = null;
            this.roles = null;
        }
    
        public boolean isInRole(String role) {
            if (roles == null) {
                return false;
            }
            for (String r : roles) {
                if (r.equals(role)) {
                    return true;
                }
            }
            return false;
        }
    }
    
  3. FilterTest を以下のように書き換えます
    処理の内容を簡単に説明すると、test と言うユーザ名とパスワードに合致すれば、"認証できてるよ"という文字列を返却します。
    それ以外は、401 を返却し、レスポンスメッセージに "ごめん!やっぱ認証できてないよ" と表示します。
    public class FilterTest implements Filter {
        @Override
        public void doFilter(
                ServletRequest servletRequest,
                ServletResponse servletResponse,
                FilterChain filterChain
        ) throws IOException, ServletException {
            HttpServletRequest hreq=(HttpServletRequest) servletRequest;
            HttpServletResponse hres=(HttpServletResponse) servletResponse;
    
            HttpSession session = hreq.getSession();
    
            if (session.getAttribute("USER_INFO") == null) {
                String auth = hreq.getHeader("Authorization");
    
                if (auth == null) {
                    hres.setStatus(401);
                    hres.setHeader("WWW-Authenticate", "Basic realm=\"example\"");
                    return;
                }else {
                    try {
                        String decoded = decodeAuthHeader(auth);
    
                        System.out.println(decoded);
    
                        int pos = decoded.indexOf(":");
                        String username = decoded.substring(0, pos);
                        String password = decoded.substring(pos + 1);
    
                        if (username != null && password != null) {
                            hres.getWriter().write("認証できてるよ\n");
                        }
                        UserInfo user = authenticateUser(username, password);
    
                        if (user == null) {
                            hres.setStatus(401);
                            hres.setHeader("WWW-Authenticate", "Basic realm=\"example\"");
                            hres.getWriter().write("ごめん!やっぱ認証できてないよ\n");
                            return;
                        }
                        else {
                            session.setAttribute("user", user);
                        }
    
                    } catch (Exception e) {
                        requireAuth(hres);
                        return;
                    }
                }
            }
            filterChain.doFilter(hreq, hres);
        }
    
    
        private void requireAuth(HttpServletResponse hres) throws IOException {
            hres.setHeader("WWW-Authenticate", "Basic realm=\"Authentication Test\"");
            hres.sendError(HttpServletResponse.SC_UNAUTHORIZED);
        }
    
        private String decodeAuthHeader(String header){
                String ret = "";
    
                try {
                    String encStr = header.substring(6);
    
    //            sun.misc.BASE64Decoder decoder=new sun.misc.BASE64Decoder();
                    Base64.Decoder decoder = Base64.getDecoder();
                    byte[] dec = decoder.decode(encStr);
    
                    ret = new String(dec);
                } catch (Exception e) {
                    ret = "";
                }
    
            return ret;
        }
    
        private UserInfo authenticateUser(String username, String password) {
            UserInfo u = new UserInfo();
    
            if (username.equals("test") && password.equals("test")) {
                u.userId = username;
                u.password = password;
                u.roles = new String[]{"User"};
            }else {
                u = null;
            }
            return u;
        }
    }
    
  4. Tomcat サーバを再起動し、以下のリクエストを送信します
    • URI はこれまでと同じ http://localhost:8080/servlet_api_war_exploded/sample-test
    • 認証を Basic 認証に設定し、ユーザ名・パスワード両方に test を入力
  5. 画像のようなレスポンスが返却されれば、認証の作成が完了です

    パスワード欄に fail と入力して、認証が通らないようにしてみると、以下の画像のようになります。

401 が返却され、所定のパスワード以外は弾く仕様になっていることが分かります。
もちろん、これはコードにパスワードをベタ書きしているので、セキュリティ的な面では甘々ですが、今回の目的は検証なので、目的は達成できたかと思います。

終わりに

簡単な Servlet での検証でしたが、Spring Security がどのようにして Filter を利用しているか、なんとなくイメージできたのではないかと思います。
流石に、Spring Security はもうちょっと高級に実装されているかと思いますので、あくまでざっくり理解という建て付けでお願いします。
読んでいただきありがとうございました。

Discussion