SIer だけど技術やりたいブログ

Spring Securityで特定ヘッダーがついてるときだけ認証をパスさせる方法

Java Spring SpringBoot SpringSecurity

以下、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

認証(そのユーザーが誰であるか?)はちゃんとやったほうがいい。そもそも誰かわからないけど認証は通そう、と考えた時点で負け。