以下、Basic認証を例に取り上げる。
何をしたいか?
アプリケーションに認証をかける。
$ curl -i -u 'user:pass' localhost:8080/api/sample 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 Set-Cookie: JSESSIONID=186E77921BB05AA75327DEFF3202125B; Path=/; HttpOnly Content-Type: text/plain;charset=UTF-8 Content-Length: 7 Date: Sun, 17 Dec 2017 13:25:35 GMT success
認証情報がなかったらエラーを返す。
$ curl -i localhost:8080/api/sample HTTP/1.1 401 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 Set-Cookie: JSESSIONID=EE0BACCC9276CAA6DFFC507733ECF0E3; Path=/; HttpOnly WWW-Authenticate: Basic realm="Realm" Content-Type: application/json;charset=UTF-8 Transfer-Encoding: chunked Date: Sat, 23 Dec 2017 07:25:39 GMT {"timestamp":1514013939184,"status":401,"error":"Unauthorized","message":"Full authentication is required to access this resource","path":"/api/sample"}
ここに、特定ヘッダーがついてるとき(secret-key:secret)だけ認証をパスさせたい。
特定のユーザーでログインさせるのではなく、認証をパスさせるだけ。
$ curl -i -H 'secret-key:secret' localhost:8080/api/sample 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: text/plain;charset=UTF-8 Content-Length: 7 Date: Sun, 17 Dec 2017 13:25:58 GMT success
結論
認証(そのユーザーが誰であるか?)はちゃんとやったほうがいい。そもそも誰かわからないけど認証は通そう、と考えた時点で負け。
結論までの道のり
以下のアプリを用意して説明する。
- Java8
- Spring Framework 4.3.12.RELEASE
- Spring Boot 1.5.8.RELEASE
- Spring Security 4.2.3.RELEASE
@SpringBootApplication @RestController public class DemoApplication { @GetMapping("api/sample") public String sample() { return "success"; } public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }
実装例その1
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http .httpBasic().and() .authorizeRequests() .mvcMatchers("/**").access("hasRole('ROLE_USER') or @myBean.hasHeader(request)") .anyRequest().authenticated(); } @Bean public MyBean myBean() { return new MyBean(); } static class MyBean { public boolean hasHeader(HttpServletRequest request) { return "secret".equals(request.getHeader("secret-key")); } } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication() .withUser("user").password("pass").roles("USER"); } }
実装のポイント
- hasRole() を使わずに access() を使うこと
- ヘッダーがついてれば許可する というOR条件がaccess()じゃないと表現できない
- ヘッダーの有無で認証okを返すカスタムルールを用意すること
- EL式内では@でBeanにアクセスできる
カスタムルールの詳細は昔にまとめた。
kimulla.hatenablog.com
注意点
認証情報にはアクセスできないので、アプリケーション内で利用するときはNPEに注意。
@GetMapping("api/self") public String self(@AuthenticationPrincipal UserDetails userDetails) { return userDetails.getUsername(); }
アクセスすると、認証情報がnullなのでNPEになる。
$ curl -i -H 'secret-key:secret' localhost:8080/api/self HTTP/1.1 500 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;charset=UTF-8 Transfer-Encoding: chunked Date: Sat, 23 Dec 2017 07:34:11 GMT Connection: close {"timestamp":1514014451615,"status":500,"error":"Internal Server Error","exception":"java.lang.NullPointerException","message":"No message available","path":"/api/self"}
アプリケーションに無邪気な気持ちで脆弱性を埋め込みたいときにどうぞ!
そしてもっと適切な方法を教えてもらった。最高や。
つRequestHeaderAuthenticationFilter
— しんどー (@shindo_ryo) 2017年12月23日
(クラス名長い…) https://t.co/v1Yv0kny7z
実装例その2
RequestHeaderAuthenticationFilter を利用する。
principalが誰かを表現するオブジェクト。
credentialsがprincipalが正しいことを証明するためのオブジェクト。
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.addFilter(requestHeaderAuthenticationFilter()) .httpBasic().and() .authorizeRequests() .mvcMatchers("/**").hasRole("USER") .anyRequest().authenticated(); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication() .withUser("user").password("pass").roles("USER"); } RequestHeaderAuthenticationFilter requestHeaderAuthenticationFilter() throws Exception { RequestHeaderAuthenticationFilter filter = new RequestHeaderAuthenticationFilter(); filter.setPrincipalRequestHeader("name"); filter.setCredentialsRequestHeader("secret-key"); PreAuthenticatedAuthenticationProvider provider = new PreAuthenticatedAuthenticationProvider(); provider.setPreAuthenticatedUserDetailsService(token -> { // 本来は認証チェックするのはProviderの役割だけどめんどいのでここでやる if ("secret".equals(token.getCredentials())) { return new User(token.getPrincipal().toString(), "", AuthorityUtils.createAuthorityList("ROLE_USER")); } else { throw new BadCredentialsException(token.getCredentials().toString()); } }); AuthenticationManager manager = new ProviderManager(Arrays.asList(provider)); filter.setAuthenticationManager(manager); return filter; } }
ちゃんと認証しているため、実装例その1とはちがい、認証情報にアクセスできる。
$ curl -i -H 'secret-key:secret' -H 'name:backend' localhost:8080/api/self 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 Set-Cookie: JSESSIONID=002D77A8917D49EE9E9ADD490266F6ED; Path=/; HttpOnly Content-Type: text/plain;charset=UTF-8 Content-Length: 7 Date: Sat, 23 Dec 2017 10:13:01 GMT backend
認証(そのユーザーが誰であるか?)はちゃんとやったほうがいい。そもそも誰かわからないけど認証は通そう、と考えた時点で負け。