spring.svg

【Spring Boot】認証・認可(REST API)

SpringBoot

認証・認可の流れ

REST API の認証・認可には、セッションを使わず認証トークンを用います。 セッションを使ってはいけないというルールはありませんが、 REST のステートレスの考え方から認証トークンを使用する方がメジャーです。

認証トークンは、認証成功時に生成してクライアントに送信します。

クライアントは、認可の必要な API に対して認証トークンを含めたリクエストを送信します。 この認証トークンが正しいものであることを検証し、API の処理を実行します。

認証トークンはリクエストヘッダーのAuthorizationに設定して送信します。

GET /sample-data HTTP/1.1
Authorization: Bearer <認証トークン>

認可に失敗した場合は、403 Forbidden のレスポンスを返します。

認証トークン

JWT とは

認証トークンの生成には、JWT(JSON Web Token)がよく利用されます。

参考:jwt.io

詳細は割愛しますが、JSON 形式のデータを電子署名することで改ざんを検知します。

JWT は Header、Payload、Signature で構成され、Payload にユーザーの情報を含めてエンコードします。 認可時にデコードして、Payload の情報を検証します。

パッケージ読み込み

Spring Boot で JWT を扱うために、以下のパッケージを読み込みます。 バージョンは随時変更してください。

Gradle
compile 'io.jsonwebtoken:jjwt-api:0.11.2'
runtime 'io.jsonwebtoken:jjwt-impl:0.11.2'
        'io.jsonwebtoken:jjwt-jackson:0.11.2'

JWT の生成

public String generateToken(String id, Date expiration, String key) {
  return Jwts.builder()
             .setSubject(id)
             .setExpiration(date)
             .signWith(Keys.hmacShaKeyFor(key.getBytes(StandardCharsets.UTF_8)))
             .compact();
}

Payload のSubjectには、ユーザーを判断するための情報を設定します。

Expirationは有効期限をDate型で指定します。有効期限を過ぎた場合は、デコード時に例外が発生します。

signWith()は暗号化に使用するキーを指定します。サーバー側のみが知る情報でかつ複雑な物を用意します。

JWT の解析

public String getIdFromToken(String token, String key) {
  return Jwts.parserBuilder()
             .setSigningKey(Keys.hmacShaKeyFor(key.getBytes(StandardCharsets.UTF_8)))
             .build()
             .parseClaimsJws(token)
             .getBody()
             .getSubject();
}

生成した認証トークンから、Payload のSubjectの値を取得します。

setSigningKey()には、生成時のsignWith()と同じ値を設定します。

認可処理

クライアントからのリクエストの情報から、認証トークンを取得して認可処理を行います。

public boolean authorize(HttpServlet request) {
  //Authorizationの値を取得
  String authorization = request.getHeader("Authorization");
  if (authorization == null || authorization.isEmpty()) {
    return false;
  }
  //Bearer tokenの形式であることをチェック
  if (authorization.indexOf("Bearer ") != 0) {
    return false;
  }
  //トークンを取得しidを取得する
  String token = authorization.substring(7);
  String id = getIdFromToken(token, key);
  //TODO idの検証を行う
  return true;
}

認可の共通化

認可の必要な API すべてに、認可処理を記述するのは大変手間なので、共通化することを考えます。

まずは、パスによって認可の必要な API であることを識別するようにします。 ここでは、http://localhost:8080/api/v1/auth/sample-dataのように、パスに/authを含む API を認可が必要な API とします。

Interceptor クラスの作成

Interceptorは、Controllerの実行前後に実行したい共通の処理を定義することができます。 以下のようにHandlerInterceptorを実装したクラスを作成します。

public class AuthorizationHandlerInterceptor implements HandlerInterceptor {

  @Override
  public boolean preHandle(
      HttpServletRequest request HttpServletResponse response, Object handler) {
    if (!authorize(request)) {
      throw new ResponseStatusException(HttpStatus.FORBIDDEN);
    }
    return true;
  }
}

preHandleが実行前、postHandleが実行後の処理となります。 今回はControllerの実行前に認可処理を行いたいため、preHandleをオーバーライドします。

Interceptor の登録

Interceptorの処理を実行するためには、WebMvcConfigureへの登録が必要になります。

@Configuration
public class WebMvcConfig implements WebMvcConfigure {

  @Bean
  AuthorizationHandlerInterceptor authorizationHandlerInterceptor() {
    return new AuthorizationHandlerInterceptor();
  }

  @Override
  public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(authorizationHandlerInterceptor())
                   .addPathPatterns("/api/v1/auth/**");
  }
}

WebMvcConfigureには、 Interceptorを登録するためのaddInterceptorsが宣言されています。

引数のInterceptorRegistryに対し、先ほど作成したInterceptorクラスのインスタンスを追加します。 追加する際に、addPathPatternsで実行するパスの制限を設定することができます。

アノテーションの利用

上記は、パスに/authを含めることで認可を実行するかを判断していました。 これが最もシンプルな方法ではありますが、パスが長くなってしまうし、あまり一般的ではありません。

そこで、以下の@Authorize@NonAuthorizeというアノテーションを実装することを考えます。

@Authorize

  • メソッドに付与した場合、認可処理を実行する
  • Controllerに付与した場合、すべてのメソッドで認可処理を実行する

@NonAuthorize

  • メソッドに付与した場合、認可処理は実行しない
  • @Authorizeより優先順位が高い

アノテーションの実装

以下のように@Authorize@NonAuthorizeを作成します。

@Authorize
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Authorize {
}
@NonAuthorize
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface NonAuthorize {
}

アノテーションの作成について、詳細は割愛します。 @Authorizeはクラスとメソッド、@NonAuthorizeはメソッドに付与するアノテーションとなるように@Targetを設定します。

Interceptor の改修

先程作成したInterceptorを、アノテーションの情報を読み取って認可処理を実行するように改修していきます。

アノテーションの情報は、AnnotationUtilsクラスのfindAnnotation()を使用することで、クラスまたはメソッドから指定したアノテーションを抽出できます。 Controllerクラスやメソッドは、引数のhandlerから取得することができます。

@Override
public boolean preHandle(
    HttpServletRequest request HttpServletResponse response, Object handler) {
  //キャスト判定
  if (!(handler instanceof HandlerMethod)) {
    return true;
  }
  //実行されるメソッドを取得
  Method method = ((HandlerMethod) handler).getMethod();
  //@NonAuthorizeが付与されているか確認
  if (AnnotationUtils.findAnnotation(method, NonAuthorize.class) != null) {
    //付与されている場合は認可せずに終了
    return true;
  }

  //メソッドに対応するControllerを取得
  Class<?> controller = method.getDeclaringClass();
  //Controllerまたはメソッドに@Authorizeが付与されているか確認
  if (AnnotationUtils.findAnnotation(controller, Authorize.class) != null
      || AnnotationUtils.findAnnotation(methods, Authorize.class) != null) {
    //付与されている場合は認可処理を実行
    if (!authorize(request)) {
      throw new ResponseStatusException(HttpStatus.FORBIDDEN);
    }
  }

  return true;
}

最初のキャストの判定は、Controllerクラスのメソッドが実行されるかの判定になります。 CORS のプリフライトリクエスト(OPTIONS)や存在しないパスへのアクセス(404)などは、Controllerクラスのメソッドが実行されず、 handleHandlerMethod以外のインスタンスとなります。

あとは、Interceptor登録時に指定するパスパターンを変更します。

使用例

パターン 1

@RestController
@RequestMapping("/api/v1/sample")
@Authorize
public class SampleController {

  @GetMapping
  @NonAuthorize
  public List<SampleData> getAll() {
    //...
  }

  @PostMapping
  public void create(@RequestBody @Validated SampleData sample) {
    //...
  }
}

パターン 2

@RestController
@RequestMapping("/api/v1/sample")
public class SampleController {

  @GetMapping
  public List<SampleData> getAll() {
    //...
  }

  @PostMapping
  @Authorize
  public void create(@RequestBody @Validated SampleData sample) {
    //...
  }
}