解説
HTTP認証の種類
HTTPの認証はRFC2617で規定されています。1つはBasic認証で、もう1つはDigestアクセス認証です。
Basic認証
HTTPリクエストヘッダに、ユーザー名とパスワードの組をBase64でエンコードして送信します。Bas64はバイナリデータを文字列化するための標準的な手法のため、平文の利用と変わりません。このため、盗聴に対して弱い認証方法です。
Digestアクセス認証
HTTPリクエストヘッダに、ユーザー名とパスワード、およびnonceと呼ばれるサーバーが発行する固有の文字列やURIを組み合わせたハッシュ(メッセージダイジェスト)を生成して送信します。ハッシュから元の文字列を求めることはできないため、Basic認証と異なりパスワードが盗聴により暴露されることはありません。また本記事では利用していませんが、Digestアクセス認証には、コンテント全体の完全性(改ざんされていないこと)を保証するためのプロトコルも用意されています。ただし、Digestアクセス認証が標準で利用するMD5は衝突に対する脆弱性が発見されているため、完全性については信用できないと考えたほうが無難だと思います。
ところで、J2EEでWebアプリケーションを開発する場合に良く利用される、フォーム認証が出て来ないことを疑問に思われるかも知れません。フォーム認証は、HTTPというプロトコルでサポートされた認証方式ではなく、Webアプリケーションのフレームワークによる認証なので、ここには出て来ません。
ユーザーから見た場合、Basic認証やDigestアクセス認証と、フォーム認証の差は認証ダイアログをブラウザが表示するかWebアプリケーションがフォームとして表示するかの違いですが、HTTP上では認証情報がリクエストヘッダに含まれるのかそれともコンテントボディに含まれるのかといった根本的な差となります。
認証処理の実装
認証処理は、前回取り上げたHttpServerクラスに対し、実際に認証処理を行うクラス(Authorityクラス)を追加するようにして実装しています(図)。

HttpServerの利用者は、引数として認証の種類を指定してserviceメソッドを呼び出します。
次のリストはHttpServerのmainメソッドで、Digestアクセス認証を指定してHttpServerのserviceメソッドを呼び出している個所です。
HttpServer server = new HttpServer(port);
server.setDebug(debug);
server.service(Authority.DIGEST);
server.close();
呼び出されたserviceメソッドでは、指定された認証の種類に対応したオブジェクトをAuthorityクラスのファクトリメソッドから取得してフィールドに設定します。
public void service(String authtype) throws IOException { assert serverSocket != null; authority = Authority.newAuthority(authtype);
実際にAuthorityオブジェクトを利用するのは、リクエスト処理の過程です。
次のリストは、HttpServer.Requestオブジェクト(個々のクライアントごとのリクエストを処理するクラス)のコンストラクタです。リクエストヘッダをすべて読み終えた時点で、認証処理を呼び出しています。
Request(Socket sock) throws IOException { assert authority != null; in = sock.getInputStream(); header(); // この時点でヘッダメタデータは確保され、 // InputStreamにはコンテンツが残っている。 if (debug) { System.out.println(this); for (int i = 0; i < metadata.length; i++) { System.out.println(metadata[i]); } } authority.authorize(metadata); }
Authority#authorizeは、ヘッダメタデータ(リクエストヘッダデータ)を引数に取り、認証するメソッドです。認証出来ない場合には例外をスローするため、呼び出しの戻り値判定はありません。
認証に失敗した場合、サーバーはクライアントへエラーを返します。この時、サーバーは認証のための情報をレスポンスヘッダへ含めます。
void response(int stat, String msg, OutputStream out) throws IOException { PrintWriter prn = new PrintWriter(out); prn.print("HTTP/1.1 "); prn.print(stat); prn.print(" "); prn.print(msg); prn.print("\r\n"); authority.challenge(prn); prn.print("\r\n\r\n"); prn.flush(); }
以上の2個所を見ておわかりのように、Authorityクラスを利用するHttpServerクラスは、認証を行うか行わないか、あるいはBasic認証を利用するのかDigestアクセス認証を利用するのかに関わらず、常にAuthorityオブジェクトの同一メソッドを呼び出します。
多態とNullObjectパターン
Javaのようなオブジェクト指向言語を利用する場合、プログラミングで意識すべきことは、条件判断を可能な限り少なくすることです。
たとえば、上に引用したauthorizeメソッド呼び出しの個所は、非オブジェクト指向言語では以下のようなコードになるのではないでしょうか。
if (authority != null) { // もし認証処理を行うのであれば if (authority.type == Authority.BASIC) { // Basic認証処理 authority.basicAuthorize(metadata); } else if (authority.type == Authority.DIGEST) { // Digestアクセス認証処理 authority.digestAuthorize(metadata); } else { assert false; } }
構造化言語によってgotoが追放された(少なくても推奨されなくなった)理由は、コードが複雑化しバグが入りやすくなったり、制御の流れを追いにくくなるため保守性が損なわれることでした。
オブジェクト指向言語の特徴として、オブジェクトによるモデルの写像や、継承による再利用性ということが良く言われますが、おそらくそれ以上に重要なことは、多態を利用することで、コードから条件判断による複数のブロックを利用した制御構造を排除できることです。
上のif文を利用したコードと次のリストを見比べれば、多態を利用することによるコードの読みやすさの向上は明らかです。
authority.authorize(metadata);
多態によるコードの整理という意味で、この例であればBasic認証とDigestアクセス認証の2つについては、いずれも認証処理の実体があるため通常は多態を利用することになると思います。しかし、認証しない場合については認証処理がそもそも存在しないため対応するクラスも定義されず、したがって次のようにコードすることはありがちです。
if (authority != null) { authority.authorize(metadata); }
NullObjectパターンは、このような本来nullかどうかの条件判断が必要な場合であっても条件判断を不要にするために、nullの場合の処理を実装したオブジェクトを用意するデザインパターンです。
以下にAuthorityクラスでの、NullObjectパターンの実装方法について説明します。なお、実装でstaticネステッドクラスを利用しているのは、ソースファイルの数を少なくしたいことが理由で、必要性があるわけではないことに注意してください。おそらくNullAuthクラスについてはわざわざ独立させるほどの処理があるわけでもないのでstaticネステッドクラスで構わないと思いますが、DigestAuthクラスは独立したソースファイルにしたほうが修正の可能性から望ましいでしょう。
public static Authority newAuthority(String type) { if (BASIC.equals(type)) { return new BasicAuth(); } else if (DIGEST.equals(type)) { return new DigestAuth(); } return new NullAuth(); }
ファクトリメソッドでは、引数から認証の型を判定してBasic認証、Digestアクセス認証、そして認証無しの場合、それぞれのオブジェクトを生成しています(より堅牢にコードするのであれば、BASIC、DIGEST、nullに当てはまらない場合はIllegalArgumentExceptionをスローするようにすべきでしょう)。多態を利用する場合、この例のように条件判断はオブジェクト生成時に1回だけ行われることになります。
/** * 認証を行う。 * @param metadata ヘッダメタデータの配列 */ public void authorize(String[] metadata) { for (int i = 0; i < metadata.length; i++) { Matcher m = AUTHORIZATION.matcher(metadata[i]); if (m.find()) { if (checkCredentials(m.group(1))) { return; } break; } } handleNoCredential(); } /** * レスポンスヘッダを出力する。 * @param writer 出力先 */ public abstract void challenge(PrintWriter writer) throws IOException; /** * 証明書を検証する。 * 既定ではすべてエラーとする。 * @param credentials 証明書 * @return 不正ならば偽(またはAuthorizationExceptionをスロー) */ boolean checkCredentials(String credentials) { return false; } void handleNoCredential() { throw new AuthorizationException("Authorization Required"); }
HttpServerが呼び出している2つのメソッドのうち、認証を行うauthorizeメソッドはテンプレートメソッドで、デフォルトでは認証エラーとなるように実装しています。また認証情報を出力するchallengeメソッドは抽象メソッドです。
これらのうち、単純なNullAuthとBasicAuthの実装を次に示します。DigestAuthは認証のためのフィールドが多く、RFC2617の詳細に入る必要があるためここでは割愛します。
static class NullAuth extends Authority { boolean checkCredentials(String credentials) { return true; } void handleNoCredential() { } public void challenge(PrintWriter writer) throws IOException { } } static class BasicAuth extends Authority { boolean checkCredentials(String credentials) { Matcher m = BASIC_CREDENTIAL.matcher(credentials); if (m.find()) { String upass = new String(Base64Util.decode(m.group(1))); if ("web:2.0".equals(upass)) { return true; } } return super.checkCredentials(credentials); } public void challenge(PrintWriter writer) throws IOException { writer.print("WWW-Authenticate: "); writer.print("Basic"); printRealm(writer); } }
NullAuthの場合、認証は無条件にOK、認証情報の出力は何も行わない、という実装です。一方、BasicAuthの方は、ユーザー名/パスワードの検証や認証情報の出力を行います。
このように、行わないという場合も含めてオブジェクトを作ることで、呼び出し側での条件判断を不要とするというNullObjectパターンは非常に重要な実装テクニックなので、ぜひともプログラミングに取り入れるようにしてみましょう。
たとえば、こういうことも可能だという例を以下に示します。
// null判断をHttpServerで行う場合 if (authority != null) { log("with auth, type = " + authority.getClass()); authority.authorize(metadata); } else { log("no auth"); } // NullObjectを利用する場合 public void authorize(String[] metadata) { log("auth:" + getClass()); ...
NullObjectを利用するということは、必ず特定のクラスのメソッドが呼ばれるということなので、このような場合に修正個所が少なくて済む(あるいは漏れにくい)ということや、将来的にAOPフレームワークの適用が可能となるということもメリットです。
メッセージダイジェストの利用方法
Javaでメッセージダイジェストを利用するには、java.security.MessageDigestクラスを利用します。
MessageDigestクラスも、上のAuthorityクラスのようにstaticなファクトリメソッドによってサブクラスのインスタンスを取得するパターンを実装しているため、直接newするのではなく、利用するアルゴリズムを指定してインスタンスを取得します。
static String md5(String s) throws NoSuchAlgorithmException { MessageDigest md = MessageDigest.getInstance("MD5"); return toLowerHex(md.digest(s.getBytes())); }
ここではハッシュ対象のデータが少ないため2行で済ませていますが、ファイルのように大きなサイズのデータをハッシュする場合には、MessageDigest#udpateメソッドを呼び出して、最後にdigestメソッドで結果を得るようにします。
byte[] buff = new byte[BUFFSIZE]; int len; while ((len = input.read(buff)) > 0) { md.update(buff, 0, len); } byte[] result = md.digest();
なお、既に触れたようにMD5(およびSHA-1)には問題が見つかっています。本記事で扱っているHTTPのDigestアクセス認証についてはともかくとして、ハッシュを利用するアプリケーションを開発する場合、もしアルゴリズムを選択できるのであれば、J2SE 5以降で実装されているSHA-256以上を利用した方が良いでしょう。
また、暗号分野の専門家は問題が見つかることを前提として、アルゴリズムを交換できるようにアプリケーションを開発することを勧めています。J2SEのMessageDigestはファクトリメソッドに利用したいアルゴリズム名を指定することでオブジェクトを交換できることと、そのようにして生成したオブジェクトは多態によってアプリケーションからは同じオブジェクトとして扱うことができることから、比較的良い条件にあると考えることができるでしょう。
仕様と実際が異なる場合の対応
RFC2617には以下の記述があります。
qop-options = "qop" "=" <"> 1#qop-value <"> qop-value = "auth" | "auth-int" | token
message-qop = "qop" "=" qop-value qop (略)Note that this is a single token, not a quoted list of alternatives as in WWW- Authenticate.(略)
これを読む限り、リクエストヘッダに含まれるqopパラメータは、
qop=auth
のように""無しとなるはずです。最初に引用したqop-optionsは明示的に""を付けていますが、これはレスポンスヘッダのパラメータで、その理由はレスポンスはqop-valueのリストとなる可能性があるからです。
したがって、最初、DigestAuthの実装でレスポンスヘッダのqopを検証する個所は
static final Pattern DIGEST_CREDENTIAL_QOP = Pattern.compile("qop=auth");
と定義した正規表現を利用していました。HttpServer側がauthのみを利用しているため、これ以外の値(auth-intなど)をクライアントが返すことは無いからです。
しかし、実際に試してみたところ、Firefoxはqop=authと設定しますが、IE6とSafariはqop="auth"と設定することがわかりました。
このように、仕様と実装が異なることは良くあります。こういう場合に、マイクロソフト社やアップル社に修正を依頼すべきでしょうか? 少なくとも現時点でネットワークにつながっているコンピュータでは、その実装が動いているということを鑑みれば、RFCにどう書いてあるかは実際には重要ではないということになるでしょう。
そのため、上の正規表現を
static final Pattern DIGEST_CREDENTIAL_QOP = Pattern.compile("qop=\"?auth\"?");
と変更してqop="auth"でもqop=authでも受け付け可能なようにしています。
このように、実際に利用されているプログラムが仕様的には正しくないことがあります。したがって、広く利用されることを前提としたプログラムを開発する場合には、できるだけ早め早めに実際の環境でテストするようにすべきです。
まとめ
HTTPの認証には、平文をBase64でエンコードしただけのBasic認証、MD5でハッシュするDigestアクセス認証という2つの方法があります。
本記事では、これらの実装を例として
- NullObjectパターンの利用
MessageDigestクラスの利用- 仕様と実装が異なることがあるということ
などについて説明しました。
Digestアクセス認証の実装そのものはパラメータが非常に多いため、本記事では説明を割愛していますが、アーカイブのソースではある程度の実装はしてあるため、参照してください。
また、テストクラス(JUnitを利用)では、HttpURLConnectionクラスを利用して認証処理のテストを行うコードが記述してあります。このコードは、Basic認証やDigestアクセス認証を行っているサーバーに対してアクセスする、クライアントコードを記述する場合に多少の参考となるでしょう。
参考資料
- RFC2617
- J2SE APIリファレンス
- japan.linux.com 『セキュリティの大御所が集まり、代替暗号法を話し合う』 Charlie-Hosner 著、2005年11月
