AndroidのHttpURLConnectionで401 Unauthorizedをハンドリングしようとしてハマった
以前AndroidのHttpURLConnectionのPOSTの挙動がバージョン違うというのを書いたんだけど、今回もその手の記事。先に言っておくと悪いのはサーバ側。
サーバ側とHTTPで通信するときに何かしらの認証機構を入れることはよくあると思うんだけど、BASIC認証じゃなくてパラメータで認証チェックしたりすることもあるよね。で、それが認証エラーの場合にどうやってレスポンス返すかっていうのは二通り考えられるかなと。
- HTTPステータスコードで401(Unauthorized)を返す
- HTTPステータスコードは200(OK)を返してレスポンスBODY内で認証失敗を示すコードを入れる
WebAPIの設計としてどちらが正しいのかはおいといて*1、401を返す場合にサーバ側の実装をちゃんとしないとAndroidのHttpURLConnectionのバージョンで挙動が変わるので注意しましょうというね。
今回のお悩み
内容
「HttpURLConnectionクラスを使って、HTTPのレスポンスコード401を受信するとIOException食らってステータスコード判定すらできない。」
GETもPOSTも同じ。前回POSTのサンプル書いたので今回はGETで書いてみる。
サンプルコード
認証キーをパラメータにしてサーバにGET投げると認証通れば200OKでJSONが取れるみたいなやつ
サーバ側の実装はこんな感じ。
- テストサーバ(TestServer.py)
#!/usr/bin/env python # -*- coding: utf-8 -*- # curl -i http://localhost:8888/?authKey=fugafuga -> 200 # curl -i http://localhost:8888/?authKey=hogehoge -> 401 import logging import SimpleHTTPServer from BaseHTTPServer import HTTPServer from urlparse import urlparse, parse_qs import json class ServerHandler(SimpleHTTPServer.SimpleHTTPRequestHandler): def check_auth(self,params): logging.debug('params:%s' % params) if 'authKey' not in params: logging.error('param null') return False if params['authKey'][0] == 'fugafuga': return True return False def do_GET(self): logging.info(self.headers) params = parse_qs(urlparse(self.path).query) if self.check_auth(params) is False: self.send_response(401) self.end_headers() return # set response body JSON response = json.dumps({'id':1111, 'data':'AAAAA'}) self.send_response(200) self.send_header('Content-Type', 'application/json') self.send_header('Content-Length', len(response)) self.end_headers() self.wfile.write(response) PORT = 8888 if __name__ == '__main__': logging.basicConfig(level=logging.DEBUG) httpd = HTTPServer(("", PORT), ServerHandler) logging.info("httpd start at port %d" % PORT) httpd.serve_forever()
このスクリプトにcurlでアクセスした時はこうなる
- curlでアクセス(成功)
$ curl -i http://localhost:8888/?authKey=fugafuga HTTP/1.0 200 OK Server: SimpleHTTP/0.6 Python/2.7.7 Date: Wed, 10 Jun 2015 04:21:58 GMT Content-Type: application/json Content-Length: 29 {"data": "AAAAA", "id": 1111}
- curlでアクセス(認証失敗)
$ curl -i http://localhost:8888/?authKey=hogehoge HTTP/1.0 401 Unauthorized Server: SimpleHTTP/0.6 Python/2.7.7 Date: Wed, 10 Jun 2015 04:22:43 GMT
で、Androidからアクセスするコード。サンプルなのでAsyncTaskでドッカーン。
- クライアント側(Android:認証失敗)
class AsyncHttpTask extends AsyncTask<Void, Void, Void> { int responseCode; String responseMessage; @Override protected Void doInBackground(Void... params) { try{ String url = "http://xxx.xxx.xxx.xxx:8888"; String authKey = "hogehoge"; //認証キーを間違える String query = String.format("authKey=%s",URLEncoder.encode(authKey, "UTF-8")); HttpURLConnection conn = (HttpURLConnection)new URL(url + "?" + query).openConnection(); responseCode = conn.getResponseCode(); responseMessage = conn.getResponseMessage(); Log.e("OSA", String.format("%s[%d]", responseMessage, responseCode)); switch (responseCode){ case HttpURLConnection.HTTP_OK: //TODO read response body break; case HttpURLConnection.HTTP_UNAUTHORIZED: //TODO handle unauthorized break; default: //TODO handle error } }catch (Exception e){ //TODO handle error Log.i("OSA", e.getMessage(), e); } return null; } // (省略) }
実行結果
で、テストサーバを起動して、Androidから認証失敗させるコードでアクセスすると実行すると一部バージョンでこうなる。
06-10 04:50:02.573 1167-1185/osa030.hatenablog.com.httptest I/OSA﹕ No authentication challenges found java.io.IOException: No authentication challenges found at libcore.net.http.HttpURLConnectionImpl.getAuthorizationCredentials(HttpURLConnectionImpl.java:427) at libcore.net.http.HttpURLConnectionImpl.processAuthHeader(HttpURLConnectionImpl.java:407) at libcore.net.http.HttpURLConnectionImpl.processResponseHeaders(HttpURLConnectionImpl.java:356) at libcore.net.http.HttpURLConnectionImpl.getResponse(HttpURLConnectionImpl.java:292) at libcore.net.http.HttpURLConnectionImpl.getResponseCode(HttpURLConnectionImpl.java:486) at osa030.hatenablog.com.httptest.MainActivity$AsyncHttpTask.doInBackground(MainActivity.java:77) at osa030.hatenablog.com.httptest.MainActivity$AsyncHttpTask.doInBackground(MainActivity.java:63) at android.os.AsyncTask$2.call(AsyncTask.java:287) at java.util.concurrent.FutureTask$Sync.innerRun(FutureTask.java:305) at java.util.concurrent.FutureTask.run(FutureTask.java:137) at android.os.AsyncTask$SerialExecutor$1.run(AsyncTask.java:230) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1076) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:569) at java.lang.Thread.run(Thread.java:856)
バージョン毎の発生状況
Version | 発生 |
4.1.1 | する |
4.2.2 | する |
4.3 | する |
4.4.4 | しない |
5.0.0 | しない |
5.1.0 | しない |
はい、前回同様、実際に処理してるHttpURLConnectionImplの実装で挙動が違いますな。
発生理由
サーバ側がRFCにのっとってないから。
HTTP/1.1: Status Code Definitions
. The response MUST include a WWW-Authenticate header field (section 14.47) containing a challenge applicable to the requested resource.
4.3以前の実装(libcore.net.http.HttpURLConnectionImpl)はRFC通りに実装されているので、ステータスコードが401で来た時に"WWW-Authenticate"を見に行って、存在しないので「No authentication challenges found」となる。4.4以降の実装(com.android.okhttp.internal.http.HttpURLConnectionImpl)はなくても例外を出さずによしなに処理してくれるようだ。
発生させないようにする
RFC通りにサーバ側の処理を修正すればよい。でも実際はそのヘッダを見ないのでダミーのレルムを設定する。
--- TestServer.py +++ TestServer.py.new @@ -29,6 +29,7 @@ if self.check_auth(params) is False: self.send_response(401) + self.send_header('WWW-Authenticate','Basic realm="fake"') self.end_headers() return @@ -39,6 +40,7 @@ self.send_header('Content-Length', len(response)) self.end_headers() self.wfile.write(response) + PORT = 8888 if __name__ == '__main__':
- curlでアクセス(認証失敗)
$ curl -i http://localhost:8888/?authKey=hogehoge HTTP/1.0 401 Unauthorized Server: SimpleHTTP/0.6 Python/2.7.7 Date: Wed, 10 Jun 2015 04:39:32 GMT WWW-Authenticate: Basic realm="fake"
ということで、テスト用のサーバを実装するときには注意しましょうね。
Webプロトコル詳解―HTTP/1.1、Webキャッシング、トラフィック特性分析
- 作者: バラチャンダークリシュナムルティ,ジェニファーレックスフォード,Balachander Krishnamurthy,Jennifer Rexford,稲見俊弘
- 出版社/メーカー: ピアソンエデュケーション
- 発売日: 2002/04/01
- メディア: 単行本
- 購入: 2人 クリック: 18回
- この商品を含むブログ (3件) を見る
- 作者: 水野貴明
- 出版社/メーカー: オライリージャパン
- 発売日: 2014/11/21
- メディア: 大型本
- この商品を含むブログ (7件) を見る
*1:403がいいのか、200としてレスポンスボディの詳細コードで表現すべきなのかとかどういうのが一般的なんでしょうね