Spring Bootでいい感じにユーザ認証（アクセス制限）機能を実装したい話

# はじめに
はじめまして！
[フリュー Advent Calendar 2024](https://qiita.com/advent-calendar/2024/furyu)の16日目を担当するピクトリンク事業部開発部の今川です。
今回はSpring Bootでいい感じに柔軟で安全で使いやすいユーザ認証機能の実装をしたい！と頑張ったお話をまとめました。

ユーザ認証機能を実装と書いてありますが、ユーザ認証自体の実装ではなく、Spring Bootでユーザ認証処理を共通化する方法と言った方が正確かもしれません。
ユーザ認証そのものの方法は、自前の認証システムでもCognitoでも使える内容になっていますので、お役に立てば幸いです。

# 実現したかったこと
* Spring Bootで実装する全てのAPIにアクセス制限をかけたい
今回開発するシステムではSpring BootをAPIサーバとして使用します
* APIごとにアクセスできる条件を柔軟に指定できるようにしたい
開発者/利用者/管理者などのロール以外にも、ユーザーの所属部署/担当サービスなど、色々な要素を考慮したアクセス制限ができるようにしたい
* 人的ミス（アクセスできる条件の指定忘れなど）を防ぎたい
* 長期運用が想定されるシステムなので、技術的負債になりそうな要素は極力排除する

### そもそもなんでSpring Bootで認証機能を実装したの？
最近のシステム開発の現場では、AWSを導入しているところが多いと思います。
弊社でもAWSは積極的に活用していて、今回認証機能を実装した社内向けシステムにもAWSのサービスを多数採用しています。
そこで最初は、Cognito・API Gateway・ALB・IAMあたりを連携させれば、ソースコード側に認証機能の実装を書かずに、スマートに認証機能が実現できるんじゃない？と思っていました。

> * APIごとにアクセスできる条件を柔軟に指定できるようにしたい

ですがこの要件(↑)！！！　この要件が非常にネックになりました。

AWSは連携機能が豊富で、頑張れば複雑な権限設定も可能です。
ですが今回開発する社内システムで要求される柔軟な権限設定を実現するには、AWS側の仕様を熟知した上で綿密に設定内容を設計しなければならない（＝権限設定の内容がぱっと見では理解しにくくなる）ことが予想されました。
（ロール・所属部署・担当サービスなど、複数要素が権限に関係するため）

AWS側の設定だけで複雑な権限設定を実現しようとすると、後々「新規APIを作るときは、どのサービスにどんな設定が必要なんだっけ？」「新しい権限グループを作るときはAWS側の仕様に気をつけて設定しないといけないんだけど、どんな仕様だったかな…」と混乱を招く原因になりかねないと判断して、今回の社内向けシステムの認証機能はSpring Boot側で実装することにしました。

ユーザ権限がもっとシンプルな場合は、AWS側の認証設定を活用する方がコード量も減って可読性も上がるので良いかもしれないですね。


# いざ実装
Spring Bootでプロジェクトは作成済みの状態から始めたいと思います。

## 1.API(ControllerMethod)作成

```SampleController.java
@RestController
public class SampleController {
    @RequestMapping("/sample/authorize")
    private ResponseEntity<String> authorize() {
        //APIでやりたい処理
        return ResponseEntity.ok("Sampleです");
    }
}
```


## 2.全てのAPIにユーザ認証処理が走るようにする

### 2-1.ユーザ認証処理を行うHandlerInterceptorを作成
HandlerInterceptorという仕組みを使うとControllerMethodの実行前後に共通処理を挟ませることができます。
今回はAPIの実行<strong>前</strong>にユーザ認証やアクセス可否判定を行いたいので、`preHandle`という関数を作成します。

```CheckApiAccessibleInterceptor.java
@Component
public class CheckApiAccessibleInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler) throws Exception {
        
        //リクエストヘッダーからtokenを取得
        String token = request.getHeader("authorization");
        
        //TODO tokenを使ってユーザ認証＆ユーザが所属する権限グループ情報などを取得する
        //TODO ユーザが持つ権限が、APIアクセス可能条件に合うかを判定
        //TODO APIアクセス不可ユーザであれば、エラーメッセージを返す

        System.out.println("CheckApiAccessibleInterceptorのpreHandleが実行されています");
        
        return true;
    }
}
```

### 2-2.ユーザ認証処理HandlerInterceptorをWebMvcConfigurerに設定する
WebMvcConfigurerではプロジェクト全体に関わる設定ができます。
HandlerInterceptorはWebMvcConfigurerに登録して初めて自動で共通処理を走らせるようにしてくれます。

```ProjectConfigurer.java
@Configuration
@AllArgsConstructor
public class ProjectConfigurer implements WebMvcConfigurer {
    private CheckApiAccessibleInterceptor checkApiAccessibleInterceptor;

    /**
     * Controllerのmethod実行前後に実行される処理の登録
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(checkApiAccessibleInterceptor);
    }
}
```

これで全てのAPIにユーザ認証処理が走るようにすることができました！
`sample/authorize`のAPIを叩いてみると、毎回`CheckApiAccessibleInterceptorのpreHandleが実行されています`というログが出力されるはずです。


## 3.APIごとにアクセスできる条件を設定できるようにする
今回はメソッドアノテーションを使って、アクセスできる条件を簡単に指定できるようにしてみます。

### 3-1.カスタムアノテーションを作成する
アクセス制限に関係する条件を保持できる独自アノテーションを作成します。
今回は、所属部署を表す`department`とロールを表す`role`の二つの情報を、enumで指定できる形にしました。

```ApiAccessibleConditions.java
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface ApiAccessibleConditions {
    @AllArgsConstructor
    @Getter
    public enum Department {
        PICTLINK("pictlink"),
        CONTENTS_SERVICE("puri"),
        OTHER("other");

        private final String code;
    };

    @AllArgsConstructor
    @Getter
    public enum Role {
        ADMINISTER("administer"),
        OPERATOR("operator"),
        LOGGED_IN("logged_in");

        private final String code;
    };

    Department department() default Department.OTHER;
    Role role() default Role.LOGGED_IN;
}
```

これでアクセス条件指定用のメソッドアノテーションが使えるようになりました！
実際にAPIにアクセスできる条件を指定してみます。

```diff_java
@RestController
public class SampleController {
    @RequestMapping("/sample/authorize")
+   @ApiAccessibleConditions(department = ApiAccessibleConditions.Department.PICTLINK, role = ApiAccessibleConditions.Role.OPERATOR)
    private ResponseEntity<String> authorize() {
        //APIでやりたい処理
        return ResponseEntity.ok("Sampleです");
    }
}
```

メソッドアノテーションとenumを活用することで、このAPIに指定されているアクセス可能条件をわかりやすく指定できるようになりました。

### 3-2.カスタムアノテーションで指定した情報を認証機能で使えるようにする

カスタムアノテーションで指定した情報をCheckApiAccessibleInterceptorで受け取ってみます。

```diff_java
@Component
public class CheckApiAccessibleInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler) throws Exception {

+       HandlerMethod handlerMethod = (HandlerMethod) handler;
+       Method method = handlerMethod.getMethod();
+       
+       //アノテーションで指定したAPIアクセス可能条件を取得
+       ApiAccessibleConditions annotation = method.getAnnotation(ApiAccessibleConditions.class);
+       if (annotation == null) {
+           //TODO アノテーションが付与されていなかった場合はアクセス権限指定忘れエラーを出す
+       }
+ 
+       String department = annotation.department().getCode();
+       String role = annotation.role().getCode();
        
        //リクエストヘッダーからtokenを取得
        String token = request.getHeader("authorization");
        
        //TODO tokenを使ってユーザ認証＆ユーザが所属する権限グループ情報などを取得する
        //TODO ユーザが持つ権限が、APIアクセス可能条件に合うかを判定
        //TODO APIアクセス不可ユーザであれば、エラーメッセージを返す

        System.out.println("CheckApiAccessibleInterceptorのpreHandleが実行されています");
        
        return true;
    }
}
```

これで認証処理の中でAPIアクセス可能条件の情報を使えるようになりました！

アノテーションから情報が取得できない＝アクセス可能条件の指定忘れ、ということでエラーを出しています。
これによって、アクセス可能条件の指定を忘れたまま誰でもアクセスできる状態でリリースしちゃった！というミスを防いでいます。



### 3-3.API以外の通信でエラーが出ないようにする

動作確認していると、HandlerInterceptorのpreHandleはAPI(`@RestController`アノテーションが付与されたControllerMethod)以外の通信でも実行されているようです。
API以外の通信ではエラーが返らないようにエラーハンドリングします。

```diff_java
@Component
public class CheckApiAccessibleInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler) throws Exception {

-       HandlerMethod handlerMethod = (HandlerMethod) handler;
+       if (!(handler instanceof HandlerMethod handlerMethod)) {
+           // ControllerMethodではない場合はすべて許可する
+           return true;
+       }

        Method method = handlerMethod.getMethod();
        
+       if (ErrorController.class.isAssignableFrom(method.getDeclaringClass())) {
+           // Error通信の場合はアクセス可能条件チェックはしない
+           return true;
+       }
        
        //アノテーションで指定したAPIアクセス可能条件を取得
        ApiAccessibleConditions annotation = method.getAnnotation(ApiAccessibleConditions.class);
        if (annotation == null) {
            //TODO アノテーションが付与されていなかった場合はアクセス権限指定忘れエラーを出す
        }
  
        String department = annotation.department().getCode();
        String role = annotation.role().getCode();
        
        //リクエストヘッダーからtokenを取得
        String token = request.getHeader("authorization");
        
        //TODO tokenを使ってユーザ認証＆ユーザが所属する権限グループ情報などを取得する
        //TODO ユーザが持つ権限が、APIアクセス可能条件に合うかを判定
        //TODO APIアクセス不可ユーザであれば、エラーメッセージを返す

        System.out.println("CheckApiAccessibleInterceptorのpreHandleが実行されています");
        
        return true;
    }
}
```

これで全APIにユーザ認証＆アクセス制限をかける共通処理が書けました:tada:
この後は、API側でユーザの認証情報を簡単に使えるようにする機能を実装してみます。


## 4.APIでユーザの認証情報を簡単に使えるようにする
3.までの段階で、ユーザの権限によってAPIを実行できる/できないの制御ができるようになりました。
ですが、APIの中の処理でユーザの認証情報を使いたいという場合もあると思います。
そんな時のためにAPI側でユーザの認証情報を簡単に使えるようにしてみます。

### 4-1.ControllerMethodで取得したい引数の型を定義する
APIでユーザの認証情報を使いたいと思うとき、どんな形で受け取ると便利かを考えます。
今回は、認証済みユーザのIDと名前の情報を持つclassを定義しました。

```AuthorizedUser.java
public record AuthorizedUser(String id, String name) {}
```


### 4-2.ControllerMethodに任意の引数を渡すHandlerMethodArgumentResolverを作成する
HandlerMethodArgumentResolverという仕組みを使うとControllerMethodの引数に任意の引数を渡すことができます。
今回は4-1.で作成したAuthorizedUserを引数として渡してくれる仕組みを作ってみます。

```ApiAccessibleConditions.java
@Component
public class AuthorizedUserResolver implements HandlerMethodArgumentResolver {

    // ここで返却するオブジェクトが、引数として利用可能になる
    @Override
    public Object resolveArgument(
            MethodParameter       parameter,
            ModelAndViewContainer mavContainer,
            NativeWebRequest      webRequest,
            WebDataBinderFactory  binderFactory
    ) throws Exception {
    
        //リクエストヘッダーからtokenを取得
        String token = webRequest.getHeader("authorization");
        //TODO tokenを使ってユーザ認証を行い、ユーザ認証情報を取得する
    
        //TODO AuthorizedUserにユーザ認証情報を詰めてreturnする（今回はダミーデータを入れています）
        String id = "hogehoge";
        String name = "namae.myouji";
        AuthorizedUser authorizedUser = new AuthorizedUser(id, name);

        return authorizedUser;
    }
}
```

HandlerMethodArgumentResolverが実行されるのはHandlerInterceptorでAPIのアクセス制限チェックをクリアした場合だけなので、HandlerMethodArgumentResolverの中ではユーザ認証情報の取得のみを行なっています。
HandlerMethodArgumentResolver・HandlerInterceptorの両方でユーザ認証情報の取得を行なっているので、ここは後日Serviceに切り出す＆キャッシュを効かせるような対応をした方がいいなと考えています。

### 4-3.ユーザ認証情報取得HandlerMethodArgumentResolverをWebMvcConfigurerに設定する
HandlerMethodArgumentResolverもHandlerInterceptorと同じく、WebMvcConfigurerに登録して初めて自動で処理を走らせるようにしてくれます。

```diff_java
@Configuration
@AllArgsConstructor
public class ProjectConfigurer implements WebMvcConfigurer {
+   private AuthorizedOperatorResolver authorizedOperatorResolver;
    private CheckApiAccessibleInterceptor checkApiAccessibleInterceptor;

+   /**
+    * Controllerのmethod実行時に実行される引数に関する処理の登録
+    */
+   @Override
+   public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
+       argumentResolvers.add(authorizedOperatorResolver);
+   }

    /**
     * Controllerのmethod実行前後に実行される処理の登録
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(checkApiAccessibleInterceptor);
    }
}
```

これでControllerMethodでユーザ認証情報を引数として受け取れるようになりました！
実際にControllerMethodでAuthorizedUserを受け取ってみます。

```diff_java
@RestController
public class SampleController {
    @RequestMapping("/sample/authorize")
    @ApiAccessibleConditions(department = ApiAccessibleConditions.Department.PICTLINK, role = ApiAccessibleConditions.Role.OPERATOR)
-   private ResponseEntity<String> authorize() {
+   private ResponseEntity<AuthorizedUser> authorize(AuthorizedUser authorizedUser) {
        //APIでやりたい処理
-       return ResponseEntity.ok("Sampleです");
+       return ResponseEntity.ok(authorizedUser);
    }
}
```

この状態で`sample/authorize`のAPIを叩いてみると、下記のようなレスポンスが帰ってきます。
```
{"id":"hogehoge","name":"namae.myouji"}
```
`AuthorizedUserResolver(HandlerMethodArgumentResolver)`でreturnした値が自動的に`AuthorizedUser`型の引数に渡されてきていることがわかります。


### 4-4.API側でAuthorizedUserを使わないときはHandlerMethodArgumentResolverが実行されないようにする

HandlerMethodArgumentResolverは、ControllerMethodに一つ以上の引数があると実行されます。
APIでユーザ認証情報を使わない（＝AuthorizedUser型の引数を定義しない）時に裏でユーザ認証情報取得の処理が実行されてしまわないように、HandlerMethodArgumentResolverの実行条件を定義します。

```diff_java
@Component
public class AuthorizedUserResolver implements HandlerMethodArgumentResolver {

+   @Override
+   public boolean supportsParameter(MethodParameter parameter) {
+       // 引数の型がAuthorizedUserの場合のみresolveArgumentを実行する
+       return parameter.getParameterType().equals(AuthorizedUser.class);
+   }

    // ここで返却するオブジェクトが、引数として利用可能になる
    @Override
    public Object resolveArgument(
            MethodParameter       parameter,
            ModelAndViewContainer mavContainer,
            NativeWebRequest      webRequest,
            WebDataBinderFactory  binderFactory
    ) throws Exception {
    
        //リクエストヘッダーからtokenを取得
        String token = webRequest.getHeader("authorization");
        //TODO tokenを使ってユーザ認証を行い、ユーザ認証情報を取得する
    
        //TODO AuthorizedUserにユーザ認証情報を詰めてreturnする（今回はダミーデータを入れています）
        String id = "hogehoge";
        String name = "namae.myouji";
        AuthorizedUser authorizedUser = new AuthorizedUser(id, name);

        return authorizedUser;
    }
}
```




# おわりに
これでいい感じのユーザ認証機能（全APIにアクセス制限をかける共通処理＆ユーザ認証情報の簡単取得機能）、できあがりです:tada:
あとは皆さんが採用したい認証サービスに合わせて、TODOコメントの部分を実装していただければ完成です！
現状はこの実装が、各API実装時にユーザ認証処理を意識しなくてもいいベストな実装だと思っていますが、まだまだ改善ポイントがあるかもしれません。
もしこんな改善でもっと便利になるよ！という点あればコメントで教えてくださいませ。

それでは皆さま、メリークリスマス:santa_tone1:＆良いお年:sunrise_over_mountains:を〜！:wave_tone1: