初老のボケ防止日記

おっさんのひとりごとだから気にしないようにな。

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"

ということで、テスト用のサーバを実装するときには注意しましょうね。

stackoverflow.com

osa030.hatenablog.com

Webプロトコル詳解―HTTP/1.1、Webキャッシング、トラフィック特性分析

Webプロトコル詳解―HTTP/1.1、Webキャッシング、トラフィック特性分析

  • 作者: バラチャンダークリシュナムルティ,ジェニファーレックスフォード,Balachander Krishnamurthy,Jennifer Rexford,稲見俊弘
  • 出版社/メーカー: ピアソンエデュケーション
  • 発売日: 2002/04/01
  • メディア: 単行本
  • 購入: 2人 クリック: 18回
  • この商品を含むブログ (3件) を見る
Web API: The Good Parts

Web API: The Good Parts

*1:403がいいのか、200としてレスポンスボディの詳細コードで表現すべきなのかとかどういうのが一般的なんでしょうね