SpringSecurityでGlobalMethodSecurityを使ってみた

SpringSecurityにはエンドポイントに対して認証認可のコントロールをすることができますが、メソッド単位でもこれらはできるようなのでやってみました。このメソッド単位で呼び出しの許可・拒否をコントロールする機能はGlobalMethodSecurityと呼ばれるようです。

https://spring.pleiades.io/spring-security/site/docs/current/reference/html5/#jc-method

呼び出しの許可・拒否はメソッドを呼ぶ前か、メソッドを呼んだ後のどちらかで判断することができます。呼ぶ前に判断するには @PreAuthorize アノテーションを使い、呼んだ後では @PostAuthorize アノテーションを使います。これらはSpring AOPを元にして実装されていて、それぞれメソッドの呼び出し前と後に判断ロジックが実行されるという感じですかね。それぞれのアノテーションには引数としてSpELを書けるのですがあまり複雑な式を書くと保守性に影響が出るので程々にと言った印象を受けました。

PreAuthorize と PostAuthorize

まずGlobalMethodSecurityを有効にするためにコンフィグレーションを設定します。 @EnableGlobalMethodSecurity アノテーションを付与して引数で PreAuthorizeとPostAuthorizeを有効化します。

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ProjectConfig {
  ...
}

後はメソッドにPreAuthorizeで呼び出しのルールを書く感じになります。アノテーションの引数には2つ変数が登場していますね。一つが #username でこいつはメソッドの引数にあたります。もう一つは authentication でこいつはログインしているユーザの情報を持っているオブジェクトにあたります。SpEL全体で何をしているかというと、メソッドの引数のユーザー名がログインしているユーザーと同じじゃないと呼び出しに失敗するようにしています。

@Service
public class FogeFogeService {

    @PreAuthorize("#username == authentication.principal.username")
    public String getSecret(String username) {
        ...
    }

}

メソッドの呼び出しに失敗すると AccessDeniedException 例外が発生するようです。こいつはSpring MVCとかと使っている場合、HTTP ステータスコード 403 としてクライアントに呼び出す権限がないことを知らせてくれます。RestControllerでこの例外が発生するとこうなります。

{"timestamp":"2021-01-24T09:23:33.872+00:00","status":403,"error":"Forbidden","message":"","path":"/fogefoge"}

PostAuthorizeも基本的には同じですがメソッドの戻り値にアクセスできる点が異なります。SpEL内の returnObject というのがメソッドの戻り値にあたります。

@Service
public class FooService {

    @PostAuthorize("returnObject == 'admin'")
    public String getSecret(String username) {
        ...
    }

}

こんな感じでメソッドに呼び出しの許可・拒否を設定できるのですがちょっとSpELだとルール書くのが難しい場合、 PermissionEvaluator というものを使うこともできます。

PermissionEvaluator

PermissionEvaluator を使う場合はこんな感じです。

PermissionEvaluatorインターフェイスなので、まずはこいつを実装したクラスを作ります。

@Component
public class FogeFogePermissionEvaluator implements PermissionEvaluator {

    @Override
    public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {
        ...
    }

    @Override
    public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permission) {
        ...
    }

}

メソッドが2つありますね。どちらも使われ方は同じでtrueを返したときにメソッドのアクセスを許可します。これは一度にどちらかしか使われないのですが、なんで2つあるかって言うと(正直良くわかっていませんが)こんな感じになるかなと思います。

  • 許可・拒否の判断元になるオブジェクトを直接渡せる場合は上
  • 許可・拒否の判断元になるオブジェクトを直接渡せない場合はID値とかを使ってメソッド内から取得するために下

1つ目のメソッドは targetDomainObject に許可・拒否の判断材料のオブジェクトを渡すのかなと。2つ目のメソッドは targetId にユーザーIDとか、 targetType にStringで User とかを渡すような作りになるかと思います。

どちらのメソッドも permission は追加で必要な情報(保護するメソッドの操作内容など)を渡すものなのかなと。 また、Authentication は自動的にSpringがログインユーザーのものを与えてくれます。

作成した PermissionEvaluator はConfigクラスで登録します。

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ProjectConfig extends GlobalMethodSecurityConfiguration {

    @Autowired
    private FogeFogePermissionEvaluator evaluator;

    @Override
    protected MethodSecurityExpressionHandler createExpressionHandler() {
        var expressionHandler = new DefaultMethodSecurityExpressionHandler();
        expressionHandler.setPermissionEvaluator(evaluator);
        return expressionHandler;
    }

    ...

}

後はPreAuthorize、PostAuthorizeで呼び出してあげます。以下はPostAuthorizeで判断元となるオブジェクトにメソッドの戻り値を与えている例です。

@Service
public class FogeFogeService {

    @PostAuthorize("hasPermission(returnObject, 'ROLE_admin')")
    public String getSecret(String value) {
        ...
    }

}

次の例はPreAuthorizeで判断元となるオブジェクトのID値と、ターゲットタイプを与えている例です。PreAuthorizeはメソッド呼び出し前に処理がインターセプトするのでreturnObjectは使えないはずです。

@Service
public class FogeFogeService {

    @PostAuthorize("hasPermission(value, 'User', 'ROLE_admin')")
    public String getSecret(String value) {
        ...
    }

}

では小さいサンプルを作って試してみましょう。

PermissionEvaluatorのサンプル

サンプルの内容として、ユーザーごとに何らかの書類(Document)を持っているとします。書類の所有者またはレビュー者として登録された人は参照することができるとします。また、アプリケーションの管理者はすべての書類への参照、変更の権限があるとします。この条件を満たすアプリケーションを上記のGlobalMethodSecurityを使って作ってみたいと思います。

サンプルの全体はこちらのGitHubリポジトリにあります。

書類を表すDocumentはこんな感じ。所有者とレビュー者のユーザー名をフィールドに持っています。

public class Document {

    private String id;

    private String title;

    private String ownerUsername;

    private String reviewerUsername;

    public Document(String id, String title, String ownerUsername, String reviewerUsername) {
        this.id = id;
        this.title = title;
        this.ownerUsername = ownerUsername;
        this.reviewerUsername = reviewerUsername;
    }

    // getter and setter ...

}

Documentのリポジトリを作ります。シンプルなものにするためにListで管理し、初期データを登録します。

@Repository
public class DocumentRepository {

    @SuppressWarnings("SpellCheckingInspection")
    private final List<Document> documents = List.of(
            new Document("document-1", "経営計画書", "Sato", "Suzuki"),
            new Document("document-2", "営業報告書", "Tanaka", "Suzuki"),
            new Document("document-3", "サービス利用契約書", "Kurihasi", "Maede")
    );

    public Document findById(String documentId) {
        return documents.stream().filter(d -> d.getId().equals(documentId)).findFirst().orElseThrow();
    }

}

サービスクラスを作ります。ここでPostAuthorizeを使って上記の DocumentRepository を使って Document を取得するメソッドを保護します。 PostAuthorize では、SpELでhasPermission メソッドを使って後続で作る PermissionEvaluator メソッドに引数を渡します。1つ目の引数はメソッドの戻り値で、2つ目の引数は管理ユーザーのロールを表す文字列を指定しておきます。

@Service
public class DocumentService {

    private final DocumentRepository documentRepository;

    public DocumentService(DocumentRepository documentRepository) {
        this.documentRepository = documentRepository;
    }

    @PostAuthorize("hasPermission(returnObject, 'read')")
    public Document getDocument(String documentId) {
        return documentRepository.findById(documentId);
    }

}

続いて PermissionEvaluator の実装クラスを作ります。上記のSpELで引数を渡しているように、与えられる引数は2つなので、以下の実装クラスでは引数が3つのメソッドの中身を書いていきます。(第一引数のAuthenticationは自動的にSpringから与えられます。)ここに認可処理を書くわけですね。戻り値がtrueなら認可し、falseなら認可しません。

@Component
public class DocumentPermissionEvaluator implements PermissionEvaluator {

    private final Logger logger = LoggerFactory.getLogger(DocumentPermissionEvaluator.class.getName());

    @SuppressWarnings({"RedundantIfStatement", "StatementWithEmptyBody"})
    @Override
    public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {
        logger.info("evaluate permission based on {} with permission {}", targetDomainObject, permission);

        // permission と同じ authority を保持しているユーザーを認可する
        if (authentication.getAuthorities().stream().anyMatch(auth -> auth.equals(new SimpleGrantedAuthority("ROLE_admin")))) {
            return true;
        }

        var document = (Document) targetDomainObject;
        var username = authentication.getName();

        if (permission.equals("read")) {
            if (document.getOwnerUsername().equals(username)) {
                return true;
            } else if (document.getReviewerUsername().equals(username)) {
                return true;
            }
        } else {
            // other permission ...
        }

        return false;
    }

    @Override
    public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permission) {
        return false;
    }

}

あとはこのPermissionEvaluatorを使えるようにConfigurationクラスで設定しましょう。

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ProjectConfig extends GlobalMethodSecurityConfiguration {

    @Autowired
    private DocumentPermissionEvaluator documentPermissionEvaluator;

    @Override
    protected MethodSecurityExpressionHandler createExpressionHandler() {
        var expressionHandler = new DefaultMethodSecurityExpressionHandler();
        expressionHandler.setPermissionEvaluator(documentPermissionEvaluator);
        return expressionHandler;
    }

}

ユーザーを追加します。DocumentRepositoryで初期データを登録したときの内容を元にします。また、管理者として Takahasi ユーザーを追加します。ロールはこんな感じで分けてます。

  • normal: 一般
  • admin: 管理者

エンドポイントの保護方法はBasic認証とし、 document 以下のパスを保護します。

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @SuppressWarnings("SpellCheckingInspection")
    @Bean
    public UserDetailsService userDetailsService() {
        var userDetailsManager = new InMemoryUserDetailsManager();

        userDetailsManager.createUser(User.builder()
                .username("Sato")
                .password("Sato")
                .roles("normal")
                .build()
        );
        userDetailsManager.createUser(User.builder()
                .username("Tanaka")
                .password("Tanaka")
                .roles("normal")
                .build()
        );
        userDetailsManager.createUser(User.builder()
                .username("Kurihasi")
                .password("Kurihasi")
                .roles("normal")
                .build()
        );
        userDetailsManager.createUser(User.builder()
                .username("Suzuki")
                .password("Suzuki")
                .roles("normal")
                .build()
        );
        userDetailsManager.createUser(User.builder()
                .username("Maede")
                .password("Maede")
                .roles("normal")
                .build()
        );
        userDetailsManager.createUser(User.builder()
                .username("Takahasi")
                .password("Takahasi")
                .roles("admin")
                .build()
        );

        return userDetailsManager;
    }

    @SuppressWarnings("deprecation")
    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.httpBasic();
        http.authorizeRequests().mvcMatchers("document/**").authenticated();
    }

}

最後にエンドポイントのControllerクラスを作成します。RESTControllerとし、JSONオブジェクトを返すようにします。Documentはパスの一部でIDを受け取るようにしています。

@RestController
public class DocumentController {

    private final DocumentService documentService;

    public DocumentController(DocumentService documentService) {
        this.documentService = documentService;
    }

    @GetMapping("document/{documentId}")
    public Document getDocument(@PathVariable String documentId) {
        return documentService.getDocument(documentId);
    }

}

実装はこんな感じで終わりですね。

実際にエンドポイントを叩いて動きを確認してみたいと思います。IntelliJのhttpファイルを使ってリクエストを飛ばしますが、cURLなどを使ったことがあればどういったリクエストを行っているかはわかると思います。

以下のようなhttpファイルを作って...

GET http://localhost:8080/document/{{documentId}}
Authorization: Basic {{username}} {{password}}

パラメータを別ファイルから指定します。

{
  "dev": {
    "documentId": "document-1",
    "username": "Sato",
    "password": "Sato"
  }
}

document-1 を所有者である Sato ユーザで取得してみます。結果がこちら。

HTTP/1.1 200 

{
  "id": "document-1",
  "title": "経営計画書",
  "ownerUsername": "Sato",
  "reviewerUsername": "Suzuki"
}

正しく取得できてますね。

では document-1 のレビュー者である Suzuki ユーザで取得してみます。パラメータを書き換えて...

{
  "dev": {
    "documentId": "document-1",
    "username": "Suzuki",
    "password": "Suzuki"
  }
}

リクエストを飛ばしてみた結果がこちら。

HTTP/1.1 200 

{
  "id": "document-1",
  "title": "経営計画書",
  "ownerUsername": "Sato",
  "reviewerUsername": "Suzuki"
}

こちらも問題なく取れますね。

では所有者でも、レビュー者でもない Tanaka ユーザで取得してみます。パラメータを書き換えて...

{
  "dev": {
    "documentId": "document-1",
    "username": "Tanaka",
    "password": "Tanaka"
  }
}

リクエストを飛ばしてみた結果がこちら。

HTTP/1.1 403 

{
  "timestamp": "2021-01-30T11:52:45.294+00:00",
  "status": 403,
  "error": "Forbidden",
  "message": "",
  "path": "/document/document-1"
}

正しく拒否されています。

では最後に管理者で取得してみましょう。パラメータを書き換えて...

{
  "dev": {
    "documentId": "document-1",
    "username": "Takahasi",
    "password": "Takahasi"
  }
}

リクエストを飛ばしてみた結果がこちら。

HTTP/1.1 200 

{
  "id": "document-1",
  "title": "経営計画書",
  "ownerUsername": "Sato",
  "reviewerUsername": "Suzuki"
}

問題なく取得できました。