JWT認証やBasic認証を実装して「よし、認証はできた!」となった後、次に壁にぶつかるのがアクセス制御の細かさですよね。SecurityFilterChain/admin/**hasRole('ADMIN') にするのは簡単ですが、同じエンドポイントでも「管理者は全件取得できるが、一般ユーザーは自分のデータだけ」というケースはURLパターンだけでは対応できません。

そこで登場するのがメソッドセキュリティです。@PreAuthorize をメソッドに付けるだけで、サービス層に直接アクセス制御を書けます。この記事では有効化の方法から実践的なSpEL式の書き方まで、実際に使えるレベルで解説します。

メソッドセキュリティが必要になる場面

SecurityFilterChain のURL単位制御は「このパスには認証済みユーザーだけアクセスできる」という大まかな制御には十分です。でも次のようなケースでは不十分になってきます。

  • GET /api/orders/{id} で、自分の注文だけ参照できるようにしたい
  • 管理者と一般ユーザーが同じエンドポイントを使うが、返すデータを変えたい
  • サービスのメソッドが複数のコントローラーから呼ばれていて、どこかで制御漏れが起きやすい

メソッドセキュリティはFilterChainの「補完レイヤー」として機能します。どちらか一方で全部やろうとするより、役割を分けて組み合わせるのがスッキリします。

@EnableMethodSecurityを有効化する

まず @EnableMethodSecurity を設定クラスに付けて機能を有効化します。Spring Security 6以降は従来の @EnableGlobalMethodSecurity が非推奨になっているので注意してください。

@Configuration
@EnableMethodSecurity
public class SecurityConfig {
    // SecurityFilterChainの設定など
}

デフォルトで @PreAuthorize@PostAuthorize が有効になります。@Secured も使いたい場合は属性で明示します。

@EnableMethodSecurity(securedEnabled = true)

@PreAuthorizeの基本構文

@PreAuthorize はメソッド実行 に評価されます。条件を満たさなければそもそもメソッドが呼ばれないので、不正アクセスを早い段階で弾けます。

@Service
public class UserService {

    @PreAuthorize("hasRole('ADMIN')")
    public List<User> findAll() {
        return userRepository.findAll();
    }

    @PreAuthorize("hasRole('ADMIN') or hasRole('MANAGER')")
    public void updateUserStatus(Long userId, boolean active) {
        // ...
    }
}

hasRole('ADMIN') は内部で ROLE_ADMIN という文字列に変換して照合します。一方 hasAuthority('ROLE_ADMIN') はそのまま文字列一致で比較します。カスタム権限名(例: READ_PRIVILEGE)を使っている場合は hasAuthority() を使うほうが自然です。

SpEL式で認証情報を参照する

@PreAuthorize の中ではSpEL(Spring Expression Language)が使えます。authentication オブジェクト経由でログイン中のユーザー情報にアクセスできます。

// ログインユーザー名を参照
@PreAuthorize("authentication.name == #username")
public UserProfile getProfile(String username) {
    return profileRepository.findByUsername(username);
}

#username のように # を付けるとメソッドの引数をSpEL内で参照できます。authentication.name と引数を比較して「自分のプロフィールだけ取得できる」という制御が1行で書けます。

カスタムの UserDetails を実装している場合は principal 経由でフィールドにアクセスできます。

@PreAuthorize("principal.id == #userId")
public void deleteAccount(Long userId) {
    userRepository.deleteById(userId);
}

所有者チェックの実装例

「自分のリソースのみ操作できる、ただし管理者は例外」というパターンはよく使います。

@PreAuthorize("hasRole('ADMIN') or authentication.name == #order.ownerUsername")
public void cancelOrder(OrderRequest order) {
    orderRepository.cancel(order.getId());
}

SpEL式が複雑になってきたらカスタムの PermissionEvaluator を作って hasPermission() を使うと式がシンプルになります。ただ最初はSpEL直書きで十分なケースが多いです。

@PostAuthorizeの用途と使い所

@PostAuthorize はメソッド実行 に評価されます。戻り値(returnObject)を条件に使いたいときに使います。

@PostAuthorize("returnObject.ownerName == authentication.name or hasRole('ADMIN')")
public Document findDocument(Long id) {
    return documentRepository.findById(id).orElseThrow();
}

ただし注意点があります。メソッドはすでに実行されているのでDBアクセスは発生しています。副作用のある処理(データ更新など)に @PostAuthorize を使うと、処理は実行されたのに拒否される、という状況になるので原則読み取り専用の処理に限定しましょう。

@Securedとの比較

@Secured はSpELが使えず、ロール文字列を列挙するだけの旧来のアノテーションです。

@Secured({"ROLE_ADMIN", "ROLE_MANAGER"})
public void someAdminOperation() {
    // ...
}

Spring Security 6以降は @Secured の機能的な優位性はありません。新規プロジェクトなら @PreAuthorize に統一するのがおすすめです。既存コードに @Secured がある場合も、触る機会があれば少しずつ @PreAuthorize に置き換えていけば問題ありません。

アクセス拒否時の挙動

認可に失敗すると AccessDeniedException がスローされ、デフォルトでHTTP 403が返ります。未認証(ログインしていない)の場合は AuthenticationException で401になるので区別されます。

JSON APIの場合、デフォルトの403レスポンスはHTMLになることがあるので、AccessDeniedHandler をBean登録してカスタムレスポンスを返すようにするとよいです。

@Bean
public AccessDeniedHandler accessDeniedHandler() {
    return (request, response, ex) -> {
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.getWriter().write("{\"error\": \"Access denied\"}");
    };
}

これを SecurityFilterChainexceptionHandling に設定します。

@WithMockUserを使ったテスト

メソッドセキュリティのテストには spring-security-test@WithMockUser が便利です。

@SpringBootTest
class UserServiceTest {

    @Autowired
    private UserService userService;

    @Test
    @WithMockUser(roles = "ADMIN")
    void 管理者は全ユーザーを取得できる() {
        assertDoesNotThrow(() -> userService.findAll());
    }

    @Test
    @WithMockUser(roles = "USER")
    void 一般ユーザーは全ユーザー取得で403になる() {
        assertThrows(AccessDeniedException.class, () -> userService.findAll());
    }
}

@WithMockUser はSpring Securityのコンテキストに指定したロールのユーザーをセットします。実際の UserDetailsService を動かしたい場合は @WithUserDetails を使います。

AOPの仕組みで動いているメソッドセキュリティを直接テストするため、モックではなく実際のBeanを使う @SpringBootTest または @SpringBootTest(webEnvironment = NONE) が必要です。AOPの基礎が気になる方は Spring BootのAOP入門 も参考にしてください。

まとめ

メソッドセキュリティを導入するポイントをまとめます。

  • @EnableMethodSecurity を設定クラスに付けて有効化する(Spring Security 6以降)
  • @PreAuthorize をサービスのメソッドに付けてロール・権限チェックを書く
  • SpEL式で authentication.name やメソッド引数を参照すると所有者チェックも簡潔に書ける
  • @PostAuthorize は戻り値を使った制御に限定し、副作用のある処理には使わない
  • @Secured は新規では使わず @PreAuthorize に統一する
  • SecurityFilterChain はアクセス制御の入口、メソッドセキュリティは細粒度の制御、と役割を分けて考える

JWT認証の実装が済んでいれば、あとは @EnableMethodSecurity を有効化して @PreAuthorize を書くだけでRBACが動きます。JWT認証の実装については Spring BootでJWT認証を実装する方法 を参照してください。