【Spring Boot】認証・認可(REST API)
認証・認可の流れ
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 を扱うために、以下のパッケージを読み込みます。 バージョンは随時変更してください。
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
を作成します。
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Authorize {
}
@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
クラスのメソッドが実行されず、
handle
がHandlerMethod
以外のインスタンスとなります。
あとは、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) {
//...
}
}