【Spring】Spring Securityを使ってWeb APIの認証、認可を行う

2020年7月26日

やりたいこと

Spring MVCの@RestControllerを使って作成したWeb APIに対する認証、認可をリクエスト中のAuthorizationヘッダの値で行います。この認可はリクエストごとに行います。

実装方法の概要

実装方法の概要は以下の様になります。

  • リクエスト中のAuthorizationヘッダの値によって認証、認可処理を行うためのフィルター、サービスを作成する
  • Spring Securityでリクエストごとに認可処理を行うためにセッションを使用しないよう設定する。
  • @RestConrollerを付けたクラスのメソッドに必要な権限を持っているかをチェックするために@PreAuthorize(“hasAuthority('権限名’)")のように@PreAuthorizeアノテーションをつける

以下の環境で動作確認しています。

  • Java 11
  • Spring Boot 2.3.1

build.gradle 中のdependenciesは以下の通りです。

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation('org.springframework.boot:spring-boot-starter-test') {
		exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
	}
	testImplementation 'org.springframework.security:spring-security-test'
}

フィルタの作成方法

リクエスト中のAuthorizationヘッダから値を取り出すためのフィルタを以下のように作成します。

// importは省略

public class MyPreAuthenticatedProcessingFilter extends AbstractPreAuthenticatedProcessingFilter {

    @Override
    protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) {
        return "";
    }

    // Authorizationヘッダの値をcredentialとして返す
    @Override
    protected Object getPreAuthenticatedCredentials(HttpServletRequest request) {
        return Optional.ofNullable(request.getHeader(HttpHeaders.AUTHORIZATION)).orElse("");
    }
}

ユーザサービスの作成方法

フィルタで取り出したAuthorizationの値を使い認証済みユーザとユーザに権限を与える処理を作成します。この例では簡易的に与える権限をハードコーディングしています。

実際にはAuthorizationヘッダの値をキーにDBを検索するなどして権限リストを取得するようになると思います。

認証済みユーザの作成はorg.springframework.security.core.userdetail.Userクラスを使用します。また、ユーザに与える権限はAuthorityUtils.createAuthorityListメソッドに権限名を与えることで生成しています。

以下の例ではAuthorizationヘッダの値がkey1の場合はGetItemのみ、key2の場合はGetItemとGetEmployeeの2つの権限を持つユーザを作成する様にしています。

public class MyAuthenticationUserDetailService implements AuthenticationUserDetailsService<PreAuthenticatedAuthenticationToken> {

    @Override
    public UserDetails loadUserDetails(PreAuthenticatedAuthenticationToken token) throws UsernameNotFoundException {

        // フィルタで取得したAuthorizationヘッダの値
        var credential = token.getCredentials().toString();

        // 空の場合は認証エラーとする
        if (credential.isEmpty()) {
            throw new UsernameNotFoundException("Authorization header must not be empty.");
        }

        // 値によって与える権限を変える
        // 動作検証のためハードコーディングしている
        switch (credential) {
            case "key1":
                // GetItemという名前の権限をもつユーザを作成
                return new User("user", "", AuthorityUtils.createAuthorityList("GetItem"));
            case "key2":
                // // GetItem, GetEmployeeという名前の権限をもつユーザを作成
                return new User("manager", "", AuthorityUtils.createAuthorityList("GetItem", "GetEmployee"));
            default:
                throw new UsernameNotFoundException("Invalid authorization header.");
        }
    }
}

フィルタ、サービスの登録とセッションを使用しない設定

作成したフィルタとユーザサービスがインジェクションされるようにBean定義を行います。また、Spring Securityでセッションを使用しないよう設定します。セッションを使用しないことでWeb APIに対するリクエストごとに認可処理が行われる様にします。

また、@PreAuthorizeアノテーションを有効化するためには @EnableGlobalMethodSecurity(prePostEnabled = true)をつける必要があります。

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true) // @PreAuthorizeアノテーションを有効化
@EnableWebSecurity()
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.authorizeRequests()
                .anyRequest()
                .authenticated()
                .and()
                .addFilter(preAuthenticatedProcessingFilter())
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // セッションを使用しない
                .csrf().disable();  // CSRF対策トークンを使用しない
    }

    // 作成したユーザサービス
    @Bean
    public AuthenticationUserDetailsService<PreAuthenticatedAuthenticationToken> authenticationUserDetailsService() {

        return new MyAuthenticationUserDetailService();
    }

    // フィルター登録
    @Bean
    public PreAuthenticatedAuthenticationProvider preAuthenticatedAuthenticationProvider() {

        var preAuthenticatedAuthenticationProvider = new PreAuthenticatedAuthenticationProvider();
        preAuthenticatedAuthenticationProvider.setPreAuthenticatedUserDetailsService(authenticationUserDetailsService());
        preAuthenticatedAuthenticationProvider.setUserDetailsChecker(new AccountStatusUserDetailsChecker());

        return preAuthenticatedAuthenticationProvider;
    }

    // 作成したフィルター
    @Bean
    public AbstractPreAuthenticatedProcessingFilter preAuthenticatedProcessingFilter() throws Exception {

        var preAuthenticatedProcessingFilter = new MyPreAuthenticatedProcessingFilter();
        preAuthenticatedProcessingFilter.setAuthenticationManager(authenticationManager());

        return preAuthenticatedProcessingFilter;
    }
}

コントローラのメソッドに権限チェック処理を追加

例として商品情報を取得するためのItemコントローラと社員情報を取得するためのEmployeeコントローラを作成します。

ポイントとしてはコントローラ内のメソッドに@PreAuthorize(“hasAuthority('権限名’)")をつけることです。この記述によりメソッド実行前に必要な権限を持っているかをチェックすることができます。

Itemコントローラとレスポンスクラス

例として商品情報を返すつもりのItemコントローラを以下のように作成します。

// importは省略

@RestController
public class ItemController {

    // showメソッドを実行するにはGetItem権限を持っていなければならない
    @PreAuthorize("hasAuthority('GetItem')")
    @GetMapping("items/{id}")
    public ItemResponse get(@PathVariable("id") int id) {

        return new ItemResponse(id, "TestItem", 100);
    }
}

Itemコントローラのレスポンスとなるクラスです。ただの例なので各フィールドは適当です。

// importは省略
// Lombokを使用

@Data
@NoArgsConstructor
@AllArgsConstructor
public class ItemResponse {

    int id;
    String name;
    int price;
}

Employeeコントローラとレスポンスクラス

例として社員情報を返すつもりのEmployeeコントローラを以下のように作成します。

// importは省略

@RestController
public class EmployeeController {

    // showメソッドを実行するにはGetEmployee権限を持っていなければならない
    @PreAuthorize("hasAuthority('GetEmployee')")
    @GetMapping("employees/{id}")
    public Employee show(@PathVariable("id") int id) {

        return new Employee(id, "TestEmployee", 25);
    }
}

 Eployeeコントローラのレスポンスとなるクラスです。ただの例なので各フィールドは適当です。

// importは省略
// Lombokを使用

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Employee {

    int id;
    String name;
    int age;
}

動作確認

Authorizationヘッダの値をkey1, key2と変えて/items/{id}と/employees/{id}にGETリクエストを投げてみます。

Authorizationヘッダの値がkey1の場合は/items/{id}へのGETリクエストは成功します。

$ curl -H 'Authorization:key1' -i  http://localhost:8080/items/1

HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Tue, 21 Jul 2020 14:54:44 GMT

{"id":1,"name":"TestItem","price":100}

/employees/{id}へのGETリクエストは403エラーとなります。

$ curl -H 'Authorization:key1' -i  http://localhost:8080/employees/1

HTTP/1.1 403
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Tue, 21 Jul 2020 14:56:32 GMT

...

Authorizationヘッダの値がkey2の場合は/items/{id}へのGETリクエスト、/employees/{id}へのGETリクエストがともに成功します。

$ curl -H 'Authorization:key2' -i  http://localhost:8080/items/1

HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Tue, 21 Jul 2020 14:59:26 GMT

{"id":1,"name":"TestItem","price":100}



$ curl -H 'Authorization:key2' -i  http://localhost:8080/employees/1

HTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Transfer-Encoding: chunked
Date: Tue, 21 Jul 2020 14:58:42 GMT

{"id":1,"name":"TestEmployee","age":25}

動作確認にはcurlを使いました。curlの基本的な使い方と、curlよりWeb APIの動作確認に便利なHTTPieというツールについて別の記事で紹介しています。