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を使って作ってみたいと思います。
書類を表す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" }
問題なく取得できました。