やっと人類の悲願だった「UIWebView でBasic 認証のかかったサイトを閲覧する方法」がわかった

追記 09/01/2012

ごめんちょっと読み替えしてみたら試行錯誤した過程で、結局どーやるんだよという文章になってた。
簡潔に以下にまとめます。

アプリ起動直後に以下の configureCredential を呼び出すようにしてください。各文字列はあなたの環境のものと置き換えてください。

- (void)configureCredential
{
  // - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
  // などから呼べばよし

  NSURLCredential* creds = [NSURLCredential credentialWithUser:@"USER" password:@"PASSWORD" persistence:NSURLCredentialPersistenceForSession];
  NSURLCredentialStorage* store = [NSURLCredentialStorage sharedCredentialStorage];

  NSURLProtectionSpace* protectionSpace = [[NSURLProtectionSpace alloc] initWithHost:@"example.jp"
                                                                                port:80
                                                                            protocol:@"http"
                                                                               realm:@"Please enter your ID and password"
                                                                authenticationMethod:NSURLAuthenticationMethodDefault];
  
  [store setCredential:creds forProtectionSpace:protectionSpace];
}

realm の部分はたとえば以下のコマンドで確認できます。

curl -Iv 'http://www.chama.ne.jp/htaccess_sample/index.htm'

...

# WWW-Authenticate: Basic realm="Input ID and Password."

...

以上。



(追記)検索エンジンからここへ来た人向け

開発中アプリのサーバへのアクセスを制限したいんだけど?
"URLCredentialStorageに直接保存" の項目のコードを試してみてください。サンプルコードでいうとUSECredentialStorageViewController.m です。
ブラウザアプリでユーザーにパスワード入力される機能が作りたいんだけど?
"NSURLConnection のdelegate でNSURLCredential をセットする" の項目を参考にパスワード入力インターフェイスを作成するといいでしょう

UIWebView delegate 内で処理する

だいたい以下のdelegate で実装している。ちなみにパスワード間違えてる時などUIWebViewDelegate のwebViewDidFinishLoadl: で受け取れない仕様なのがアレである(タイムアウトで対応?)。

// UIWebViewAuthentication/DetailViewController.m

#pragma mark - UIWebViewDelegate

- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType
{
  if([request valueForHTTPHeaderField:@"Authorization"]){
    return YES;
  }
  
  
  NSMutableURLRequest* req = (NSMutableURLRequest*)request;
  NSString* authToken = [NSString stringWithFormat:@"%@:%@", USER, PASSWORD];
  
  GTMStringEncoding *coder = [GTMStringEncoding rfc4648Base64WebsafeStringEncoding];
  [req addValue:[NSString stringWithFormat:@"Basic %@", [coder encodeString:authToken]]
    forHTTPHeaderField:@"Authorization"];
  
  [webView loadRequest:req];
  
  return NO;
}
  • ようは、このUIWebViewDelegate の引数のrequest の型が実はNSMutableURLRequest であったことでいろいろ解決した。
    • iOS SDK 5.1 で試しているけど、5.0 ではどうだったかは確認していない。
  • Base64エンコードGoogle Toolbox for Mac から1クラスだけ使う、みたいなこともやってみた。
  • アプリからのアクセス先にChamaNet で公開されているサンプルのURLを使わせていただいた。
  • 任意のHTTP Header を追加、書き換えする(User-Agentとかcookie とか)っていうのもこれでいける

NSURLConnection のdelegate でNSURLCredential をセットする

NSURLCredential で設定しておくことでもいいらしい。

やってみた。ちょっと冗長になったけど以下のよう。UIWebView がアクセスする前にNSURLConnection で対象のサーバへのリクエストを発行して認証関連のdelegate が呼ばれるのでそこでBasic 認証のNSURLCredential をセットする感じ。

// USENSURLCredential/USENSURLCredentialViewController.m

- (void)viewDidLoad
{
  [super viewDidLoad];
  
  [self registerMyCredential];
  //[self configureView];
}

- (void)registerMyCredential
{
  NSURLConnection* conn = [[NSURLConnection alloc] initWithRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:DEFAULT_URL]] delegate:self];
  [conn start];
}

- (void)connection:(NSURLConnection *)connection willSendRequestForAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge
{
  NSURLCredential* creds = [NSURLCredential credentialWithUser:USER password:PASSWORD persistence:NSURLCredentialPersistencePermanent];
  [[challenge sender] useCredential:creds forAuthenticationChallenge:challenge];
}

- (void)connectionDidFinishLoading:(NSURLConnection *)connection
{
  [self configureView];
}

- (void)configureView
{
  NSString* url = DEFAULT_URL;
  [self.webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:url]]];
}

こっちのが元からあるFoundation の仕組みを利用していて自然ではあるが、2回リクエストが飛ぶ?

このNSURLCredential を使った認証ではパスワードなどは自動的にOS内のキーチェーンなどのセキュアストレージに保存されるらしく。認証が成功したらその後はNSURLCredential を発行しなくても閲覧できるようなった。

お、じゃあNSURLCredential を使った方がいいかな? というとおそらくURL にパスワード含ませた時もヘッダいじった時も内部的に同じ処理をしていると思われる。のでどれがいいのかはよくわからず。

この件 stackoverflow.com とかでも既出ではないみたいなんで見掛けたら回答しておくつもり。

さらなる方法

NSURLCredentialStorageを直接使えるかもという情報を教えてもらったので確認している。
あとNSURLCredentialPersistencePermanent ってやっていることから、サンドボックス外に認証情報が保存されているなら一端iOSを初期化しないと確認できないね。

CredentialStorage の初期化

iOS初期化(シミュレータでいう'Reset Content and Setting')めんどくさいので、起動直後にこうやった。

  // clean
  NSURLCredentialStorage* store = [NSURLCredentialStorage sharedCredentialStorage];
  [[store allCredentials] enumerateKeysAndObjectsUsingBlock:^(NSURLProtectionSpace* space, NSDictionary* credHash, BOOL *stop) {
    NSURLCredential* cred = [credHash objectForKey:USER];
    [store removeCredential:cred forProtectionSpace:space];
  }];

URLCredentialStorageに直接保存しちゃえば?

(というかよく見たら先のAkimbo App Studio のエントリに書いてあった……)

こうやってみた。が結果これは認証成功しない。
修正したらできた。url.port がnil なのに気付いていなかったようだ

- (void)setCredential
{  
  NSURLCredential* creds = [NSURLCredential credentialWithUser:USER password:PASSWORD persistence:NSURLCredentialPersistenceForSession];
  NSURLCredentialStorage* store = [NSURLCredentialStorage sharedCredentialStorage];
  
  NSURL* url = [NSURL URLWithString:DEFAULT_URL];
  NSURLProtectionSpace* protectionSpace = [[NSURLProtectionSpace alloc] initWithHost:url.host
                                                                      port:80 
                                                                  protocol:url.scheme 
                                                                     realm:@"Input ID and Password." 
                                                      authenticationMethod:NSURLAuthenticationMethodDefault];
  
  [store setCredential:creds forProtectionSpace:protectionSpace];
}

これは先に言われてるとうりREALM の一致が必須だった。REALM 知らないという場合、Xcode環境あるなら以下のコマンドで調べられる

curl -Iv 'http://www.chama.ne.jp/htaccess_sample/index.htm'

...

# WWW-Authenticate: Basic realm="Input ID and Password."

...

レスポンスヘッダの"Input ID and Password." の部分がそれ。

UIWebView のリクエストを書き換えて認証したパターンではURLCredentialStorage に保存されていない?

デバッグの過程で気付いたんだけど、一番目のUIWebView のリクエスを書き換えパターンで認証通した場合 [store allCredentials] に出てこない……
どこか別の場所で管理しているのか。とりあえずわかった事実は以下

  • Mobile Safari で同じページにアクセスしたところ未承認(認証前)だった
    • URLCredentialStorage に保存されていればSafari でもアクセスできる
  • アプリA とアプリB の二つにわけて共有されるか確認したが、共有されていなかった
    • URLCredentialStorage に保存されていればアプリA とアプリB でもアクセスできる

なぞが多い……。”URLCredentialStorageに直接保存” ができれば一番いいんだけど。→できた

もう一個わかった

  • " NSURLConnection のdelegate でNSURLCredential をセットする" のあとUIWebView で一度承認されるとURLCredentialStorage のデータを削除してもひき続き閲覧できる

つまりはUIWebView でBASIC 認証を通した時の保存先がなんかおかしい。ということ以上わからず。

まとめ: UIWebView はおかしい

iOS向けアプリ開発者がハマるの諸悪の根源はUIWebView の実装であり。業界の不況もすべてはUIWebView の実装のせいだと思っています。