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

5年目のSIerのブログです

Vuejs Axiosで共通的な例外をあつかう

Vuejsと一緒に利用されることが多いHTTPクライアントライブラリのAxiosでは、interceptorsの仕組みを利用することでレスポンスに関する共通的な処理をはさみこめる。これを利用してVuejsで共通的な例外をあつかう。

Axios interceptors
GitHub - axios/axios: Promise based HTTP client for the browser and node.js


動作確認

モックサーバを用意する

サンプルとしてエラーを返すモックサーバを用意する。

$ npm install -D json-server

モック定義のファイルを用意する。

const jsonServer = require('json-server')
const server = jsonServer.create()
const middlewares = jsonServer.defaults()

server.use(middlewares)
server.listen(3000, () => {
  console.log('JSON Server is running')
})
server.get('/unauthorized', (req, res) => {
  res.status(401).jsonp({
    message: "unauthorized"
  })
})

server.get('/systemerror', (req, res) => {
  res.status(500).jsonp({
    message: "something wrong"
  })
})

モックサーバを起動する。

$ node mock.js
JSON Server is running


システムエラーの場合は、500を返す。

$ curl -i localhost:3000/systemerror
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Vary: Origin, Accept-Encoding
Access-Control-Allow-Credentials: true
Cache-Control: no-cache
Pragma: no-cache
Expires: -1
X-Content-Type-Options: nosniff
Content-Type: application/json; charset=utf-8
Content-Length: 32
ETag: W/"20-fhnUB5BwaOsQsXyG8exFr0MEGzY"
Date: Wed, 17 Jan 2018 13:16:01 GMT
Connection: keep-alive

{
  "message": "something wrong"
}

認証エラーの場合は、401を返す。

$ curl -i localhost:3000/unauthorized
HTTP/1.1 401 Unauthorized
X-Powered-By: Express
Vary: Origin, Accept-Encoding
Access-Control-Allow-Credentials: true
Cache-Control: no-cache
Pragma: no-cache
Expires: -1
X-Content-Type-Options: nosniff
Content-Type: application/json; charset=utf-8
Content-Length: 29
ETag: W/"1d-1AQxoXvOBStoEV/A43KaFU1XEOg"
Date: Wed, 17 Jan 2018 13:16:42 GMT
Connection: keep-alive

{
  "message": "unauthorized"
}

アプリを用意する

完成版はgithubに。
GitHub - kimullamen/axios-sample


バージョンは以下の通り。

  "dependencies": {
    "axios": "^0.17.1",
    "vue": "^2.5.2",
    "vue-router": "^3.0.1",
    "vue-toasted": "^1.1.24"
  },

ポイントはAxiosのinterceptorsの仕組みを利用すること。これによってレスポンスの共通処理を作成できる。

今回はvue-toastedというトースト表示のライブラリを利用する。

import Vue from 'vue'
import Axios from 'axios'

const http = Axios.create({
  // for cors
  withCredentials: true
})
http.interceptors.response.use(function (response) {
}, function (error) {
  // 認証エラー時の処理
  if (error.response.status === 401) {
    Vue.toasted.clear()
    Vue.toasted.error(error.response.data.message)
  // システムエラー時の処理
  } else if (error.response.status === 500) {
    Vue.toasted.clear()
    Vue.toasted.error(error.response.data.message)
  }
  return Promise.reject(error)
})

export default http

Axiosをimportし、Vueのprototypeに設定する。こうすることでVueインスタンスから共通処理が設定済みのAxiosを$httpで利用できるようになる。

// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from 'vue'
import App from './App'
import router from './router'
import Axios from './axios'
import Toasted from 'vue-toasted'

Vue.prototype.$http = Axios
Vue.use(Toasted)
Vue.config.productionTip = false

/* eslint-disable no-new */

new Vue({
  el: '#app',
  router,
  components: { App },
  template: '<App/>'
})

実際のアクセス部分。$httpを利用する。

<template>
  <div id="app">
    <button type="button" @click="callUnauthorized">unauthorized</button>
    <button type="button" @click="callSystemError">system error</button>
  </div>
</template>

<script>
export default {
  name: 'App',
  methods: {
    callUnauthorized: function () {
      this.$http.get('http://localhost:3000/unauthorized')
    },
    callSystemError: function () {
      this.$http.get('http://localhost:3000/systemerror')
    }
  }
}
</script>

実行結果

HTTPレスポンスの共通処理が実行されて、トーストが表示される。

f:id:kimulla:20180118235708g:plain

結論

HTTPレスポンスの例外は共通化できる。

Vuejs vue-router利用時にはアクティブなリンクに自動でクラスを振ってくれる

タイトルでほぼ説明終わり。

  "dependencies": {
    "vue": "^2.5.2",
    "vue-router": "^3.0.1"
  },

コンポーネント内にrouter-viewタグがネストしている場合に、アクティブなリンクにcssのスタイルを適用したいとする。

f:id:kimulla:20180115225703g:plain

Vuejsだけの知識で解決しようとすると、以下のように書ける。

<template>
  <div>
    <ul>
      <!-- class属性を割り当てる -->
      <li :class="{ active : this.$route.path.match(/articles\/stark/) }">
        <router-link to="/articles/stark">Stark</router-link>
      </li>
      <li :class="{ active : this.$route.path.match(/articles\/tyrell/) }">
        <router-link to="/articles/tyrell">Tyrell</router-link>
      </li>
      <li :class="{ active : this.$route.path.match(/articles\/targaryen/) }">
        <router-link to="/articles/targaryen">Targaryen</router-link>
      </li>
    </ul>
    <router-view/>
  </div>
</template>

<script>
export default {
  name: 'App'
}
</script>

<style scoped="scoped">
.active > a{
  color: red;
  border: solid;
}
</style>

上記のように自分でアクティブなクラスを割り当てるのは、URLが変わるたびにclassの式を変える必要が出てきて変更に弱くなる。なので、なるべく控えたい。


なんかいい方法ないかなとリファレンス見たら、ズバリそのものが…!

router-link · vue-router

router-link コンポーネントはアクティブなタグには自動でクラスを振るらしく、確認すると確かにその通り。

f:id:kimulla:20180115230028p:plain

しかも exact-active-class でクラス名の変更もできるので、cssフレームワークのルールに応じた名前に変えられる、と。

  <router-link to="/article/stark" exact-active-class="active">Stark</router-link>

いやーリファレンスはこまめにチェックしないとダメですね。

Vuejs利用時に <button type="reset"> は使わないほうがいい

button type="reset"とは?

HTMLのButtonのひとつで、Formを自動でクリアしてくれるやつ。
button 要素 - HTML | MDN

<!doctype html>
<html>
  <body>
    <form>
      <input type="text" placeholder="username" />
      <input type="password" placeholder="password" />
      <button type="reset">reset</button>
    </form>
  </body>
</html>

resetボタンをクリックするとFormが自動でクリアされて地味にうれしい。

f:id:kimulla:20180108184041g:plain

Vuejsと併用すると…

resetボタン押下時に見ため上はクリアされたようにみえるけど、Vuejsで管理しているdataプロパティの値はクリアされない。

<template>
  <form @submit.prevent="save">
    <p>
      <input type="text" v-model="text" />
    </p>
    <p>
      <textarea v-model="textarea" />
    </p>
    <p>
      <input type="checkbox" v-model="checked" />
    </p>
    <p>
      <input type="radio" id="one" value="one" v-model="radio" />
      <input type="radio" id="two" value="two" v-model="radio" />
    </p>
    <p>
      <select v-model="select">
        <option disabled value="" selected>please select</option>
        <option>A</option>
        <option>B</option>
        <option>C</option>
      </select>
    </p>
    <button type="reset">reset</button>
    <button type="submit">save</button>
  </form>
</template>

<script>
export default {
  name: 'Form',
  data () {
    return {
      text: '',
      checked: false,
      textarea: '',
      radio: '',
      select: ''
    }
  },
  methods: {
    save: function () {
      console.log(`save text: ${this.text} textarea: ${this.textarea} checked: ${this.checked} radio: ${this.radio} select: ${this.select}`)
    }
  }
}
</script>

resetボタンを押したあとも、dataプロパティの値がそのまま残っている。

f:id:kimulla:20180108184615g:plain

解決方法

button type="reset"を利用しない。
自分でハンドラを設定して、dataプロパティをリセットする。

<template>
...
    <button @click="clear" type="button">reset</button>
...
</template>

<script>
...
methods: {
    ...
  clear: function () {
    this.text = ''
    this.checked = false
    this.textarea = ''
    this.radio = ''
    this.select = ''
  }
}
</script>

resetボタンを押したときに、dataプロパティもリセットされている。

f:id:kimulla:20180108190142g:plain

dataプロパティの初期値を丸ごと設定する場合は、以下のようにも書けるらしい。

<script>
...
// ここを再利用する
data () {
  return {
    text: '',
    checked: false,
    textarea: '',
    radio: '',
    select: ''
  }
},
methods: {
    ...
    clear: function () {
      Object.assign(this.$data, this.$options.data.call(this))
    }
  }
}
</script>

stackoverflow.com

ついでに

Vuetifyというフレームワークを利用していた場合、

こんな感じで組み込みのFormコンポーネントをrefで参照してreset()メソッドを呼び出せば、nullで初期化してくれる。
https://github.com/vuetifyjs/vuetify/issues/2752

<template>
...
      <v-form ref="form">
...
</template>

<script>
...
    clear: function () {
      this.$refs.form.reset()
    }
...
</script>

いやぁー、リッチなUIフレームワークって、本当(ほんっとう)に素晴らしいものですね。

今年のふりかえり(ブログの運営について)

年の瀬でやる気もでないので、2017年をふりかえろうと思います。

技術要素はゼロです。面白さもゼロです。

アクセス数

2016年から始めたブログですが、1年前と比べると2倍強のアクセス数になりました。
f:id:kimulla:20171231000828p:plain


日本の総人口は1億2670万人らしいので、このペース(1年に+6000pv)で成長し続ければ21,116年後には国民全員に読んでもらえそうです。
http://www.stat.go.jp/data/jinsui/img/pop.gif

統計局ホームページ/人口推計(平成29年(2017年)7月確定値,平成29年12月概算値) (2017年12月20日公表)


もっというと日本の人口減少は以下のように深刻なので、10,000年後には国民全員に読んでもらえるチャンスすらありそうです。そのころには、国民の生活を支えるインフラ事業のようになっているかもしれません。

http://www8.cao.go.jp/kourei/whitepaper/w-2012/zenbun/img/z1_1_03.gif

第1章 第1節 1 (2)将来推計人口でみる50年後の日本|平成24年版高齢社会白書(全体版) - 内閣府

アウトプットする利点

記事を読んでもらえてると思うと、勉強するモチベーションがわきますし、また、記事にする以上はもう少し理解しておこう、という意識が働きます。これが勉強にとても役立ちます。

自分は、OSSソースコードを読んでみようかなぁと思えるようになりました(かなり苦労しますが)。2年前は「OSSソースコードは、尖りすぎたクレイジーな人が読むもの。あそこまでイッちゃうと怖い。」と思っていたので、大きな進歩です。

勉強したいけど、ついつい土日に寝てしまう、無駄に過ごしたなぁと後悔してしまう、という人は、ぜひアウトプットしましょう。世の中に情報があれば私も助かります。

Tomcatのリソース設定(maxThreads,maxConnections,acceptCount)の違い

リファレンスだけを読んでも違いがわからなかったので、調べた。

Apache Tomcat 8 Configuration Reference (8.5.24) - The HTTP Connector

maxThreads

  • リクエストを処理するスレッド数(workerスレッド)の上限
  • 1リクエスト処理するために1workerスレッドを消費する
  • 上限はコネクタ(HTTP1.1, AJPなど)ごとの設定
  • ただしスレッドプールをexecutorプロパティで指定した場合、この上限は無視される

maxConnections

  • 同時に受け付けるコネクション数の上限
  • workerスレッドに空きがなければリクエストは処理されない
  • Nioコネクタなら maxThreads < maxConnections が可能
    • デフォルトは maxThreads=200, maxConnections=10000
    • HTTP Keep AliveごとにThreadを利用する必要がないから
    • ここらへんは昔に調べた

kimulla.hatenablog.com

acceptCount

  • Socket#accept()されないコネクション数の上限
  • 上限を超えたコネクションはTCPレベルで『connection refused』になる
  • ただしこの上限は実行環境へのヒントで、無視される可能性もある
  • maxConnectionsを超えたリクエストが来た場合、Acceptorスレッドを止めて待つ

図にしてみた。
f:id:kimulla:20171230100804j:plain

動作確認

以下の環境で動作確認した。

@RestController
@Slf4j
@SpringBootApplication
public class DemoxApplication {
  @Bean
  EmbeddedServletContainerCustomizer embeddedServletContainerCustomizer() {
    return (container) -> {
      TomcatEmbeddedServletContainerFactory factory = (TomcatEmbeddedServletContainerFactory) container;
      factory.addConnectorCustomizers(
          (connector) -> {
            Http11NioProtocol handler = (Http11NioProtocol) connector.getProtocolHandler();
            handler.setAcceptCount(1);
            handler.setMaxConnections(2);
            handler.setMaxThreads(1);
          }
      );
    };
  }

  @GetMapping
  public String hello() throws InterruptedException {
    log.info("wait a seconds");
    TimeUnit.SECONDS.sleep(5);
    return "I'm sorry, I'm late";
  }

  public static void main(String[] args) {
    SpringApplication.run(DemoxApplication.class);
  }
}
  • コンソールログからmaxThreads分しか同時実行されない
  • maxConnections + acceptCountを超えたリクエストは『connection refused』されている

f:id:kimulla:20171230112918g:plain

Vuejs URL切り替え時のコンポーネントライフサイクルについて

vue-routerのリファレンスに以下の記述がある。

動的ルートマッチング · vue-router

ルートのパラメーターを使う際に特筆すべき点は、ユーザーが /user/foo から /user/bar へ遷移するときに同じコンポーネントインスタンスが再利用されるということです。 両方のルートが同じコンポーネントを描画するため、古いインスタンスを破棄して新しいものを生成するよりも効率的です。しかしながら、これはコンポーネントのライフサイクルフックが呼ばれないことを意味しています。

どこまで再利用されるか気になったので、調べた。

調べたバージョン

  "dependencies": {
    "vue": "^2.5.2",
    "vue-router": "^3.0.1"
  }

調べた

プロパティが変更された場合

まずはリファレンスに記述のある、コンポーネント内のプロパティの変更。

ソースコード
import Vue from 'vue'
import Router from 'vue-router'
import Parent from '@/components/Parent'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/parents/:id',
      component: Parent,
      props: true
    }
  ]
})
<template>
</template>

<script>
export default {
  name: 'Parent',
  props: ['id'],
  watch: {
    id: function () {
      console.log('id changed ' + this.id)
    }
  },
  methods: {
  },
  created: function () {
    console.log('created parent')
  }
}
</script>
実行結果

当然再利用される。
f:id:kimulla:20171223234544g:plain

いったんコンポーネントを切り替えた場合

ソースコード
import Vue from 'vue'
import Router from 'vue-router'
import Parent from '@/components/Parent'
import Other from '@/components/Other'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/parents/:id',
      component: Parent,
      props: true
    },
    {
      path: '/others/:id',
      component: Other,
      props: true
    }
  ]
})
<template>
</template>

<script>
export default {
  name: 'Parent',
  props: ['id'],
  watch: {
    id: function () {
      console.log('id changed ' + this.id)
    }
  },
  methods: {
  },
  created: function () {
    console.log('created parent')
  }
}
</script>
<template>
</template>

<script>
export default {
  name: 'Other',
  props: ['id'],
  watch: {
    id: function () {
      console.log('id changed ' + this.id)
    }
  },
  methods: {
  },
  created: function () {
    console.log('created other')
  }
}
</script>
実行結果

さすがに再利用されない。
f:id:kimulla:20171223234732g:plain

別URLに同一コンポーネントを割り当てた場合

ソースコード
import Vue from 'vue'
import Router from 'vue-router'
import Parent from '@/components/Parent'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/parents-a/:id',
      component: Parent,
      props: true
    },
    {
      path: '/parents-b/:id',
      component: Parent,
      props: true
    }
  ]
})
実行結果

再利用される。
f:id:kimulla:20171223234924g:plain

ネストしたビューでプロパティが切り替わった場合

ソースコード
import Vue from 'vue'
import Router from 'vue-router'
import Parent from '@/components/Parent'
import Child from '@/components/Child'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/parents/:pid',
      component: Parent,
      props: true,
      children: [
        {
          path: 'children/:cid',
          component: Child,
          props: true
        }
      ]
    }
  ]
})
<template>
  <div>
    <router-view></router-view>
  </div>
</template>

<script>
export default {
  name: 'Parent',
  props: ['pid'],
  watch: {
    pid: function () {
      console.log('parent id changed ' + this.pid)
    }
  },
  methods: {
  },
  created: function () {
    console.log('created parent')
  }
}
</script>
<template>
</template>

<script>
export default {
  name: 'Child',
  props: ['cid'],
  watch: {
    cid: function () {
      console.log('child id changed ' + this.cid)
    }
  },
  methods: {
  },
  created: function () {
    console.log('created child')
  }
}
</script>
実行結果

親のプロパティが変わろうと子のプロパティが変わろうと再利用される。
f:id:kimulla:20171223235046g:plain

ネストしたビューで親コンポーネントが切り替わった場合

ソースコード
import Vue from 'vue'
import Router from 'vue-router'
import Parent from '@/components/Parent'
import Other from '@/components/Other'
import Child from '@/components/Child'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/parents/:pid',
      component: Parent,
      props: true,
      children: [
        {
          path: 'children/:cid',
          component: Child,
          props: true
        }
      ]
    },
    {
      path: '/others/:oid',
      component: Other,
      props: true,
      children: [
        {
          path: 'children/:cid',
          component: Child,
          props: true
        }
      ]
    }
  ]
})
実行結果

再利用されない。まあrouter-viewコンポーネント自体が親コンポーネントに紐づく要素だし、親コンポーネントのライフサイクルに左右されるんでしょう。
f:id:kimulla:20171223235120g:plain

ライフサイクルフックが呼ばれないことで発生するありがちな不具合

エラーメッセージ出しっぱなし

<template>
  <div>
    <button type="button" @click="fire">save</button>
    {{msg}}
  </div>
</template>

<script>
export default {
  name: 'Parent',
  props: ['pid'],
  data () {
    return {
      msg: null
    }
  },
  watch: {
    pid: function () {
      console.log('parent id changed ' + this.pid)
    }
  },
  methods: {
    fire: function () {
      this.msg = 'failure'
    }
  },
  created: function () {
    console.log('created parent')
  }
}
</script>

msgが初期化されずfailureが出っぱなし。
f:id:kimulla:20171224001332g:plain

created()で初期化するはずのデータが古いまま

<template>
  <div>
    {{name}}
  </div>
</template>

<script>
export default {
  name: 'Parent',
  props: ['pid'],
  data () {
    return {
      names: ['alice', 'bob', 'charie'],
      name: null
    }
  },
  watch: {
    pid: function () {
      console.log('parent id changed ' + this.pid)
    }
  },
  created: function () {
    console.log('created parent')
    this.name = this.names[this.pid]
  }
}
</script>

プロパティ変更しただけだとcreated() が呼ばれないため、nameが変わらない。
f:id:kimulla:20171224002216g:plain

解決方法

コンポーネントのデータの生成元をwatchする
  watch: {
    pid: function () {
      console.log('parent id changed ' + this.pid)
      this.name = this.names[this.pid]
    }
  }

f:id:kimulla:20171224002523g:plain

:keyを指定してコンポーネントの再利用をやめさせる
<template>
  <div id="app">
    <router-view :key="$route.fullPath"/>
  </div>
</template

f:id:kimulla:20171224002706g:plain

この方法はパフォーマンス下がるだろうし、あんまり推奨はされなそう…
個人的には画面の初期化処理をcreatedにまとめられたほうが直感的な気はするけど。

Allow for Full Component Rerender in router-view · Issue #1350 · vuejs/vue-router · GitHub

ということで以下の流れが良さげ。

  • コンポーネントを表示するときの大元のデータをwatchする
  • 初期化処理(propsの初期化含む)はmethodsに切り出す
  • watchからその初期化処理を呼ぶ
  • createdからもその初期化処理を呼ぶ

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

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

アプリケーションに無邪気な気持ちで脆弱性を埋め込みたいときにどうぞ!

そしてもっと適切な方法を教えてもらった。最高や。


実装例その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

限界を知る

で、ここまでやって、AbstractPreAuthenticatedProcessingFilter は他の場所で認証済み(SSOサーバがフロントエンドに立ってて認証ヘッダーを付与するなど)と仮定して、認証情報だけを取得するためのクラス。なので、なんかおかしい気がしてきた。

そもそも今回の特定ヘッダー(secret-key:secret)を見てok,ngを確認して認証情報を取得するのは、認証行為以外の何物でもない気がしてきた。なので、無理があるな、と。

これならちゃんと認証行為をユーザ側が行ったほうがマシかも。
そもそも参照したユーザがDBにいないと、業務ロジックでユーザidを使うとエラー吐くだろうし。
ということで 認証はちゃんとやったほうがいい というのが結論です。