以下、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にアクセスできる
カスタムルールの詳細は昔にまとめた。
SpringSecurityでEL式を拡張してカスタムルールを作るときのメモ - SIerだけど技術やりたいブログ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
(クラス名長い…) https://t.co/v1Yv0kny7z— しんどー (@shindo_ryo) 2017年12月23日
実装例その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
認証(そのユーザーが誰であるか?)はちゃんとやったほうがいい。そもそも誰かわからないけど認証は通そう、と考えた時点で負け。