何度やっても同じ

ただの日記

まずはGAE/J+slim3でOpenID、そしてOpenID認証とTwitter認証両方に対応してみる

管理画面で設定

まずは管理画面の「Application Settings」で「Authentication Options」を変更する。初期状態で「Google Accounts API」となっているのを「Federated Login」に変更して、保存。


ログインが必要なページのURLをweb.xmlに記述

この設定変更を行った上で、web.xml に次のように記述してページを保護すると、未ログイン状態で保護されたページにアクセスしたときに、Googleのログイン画面ではなく、/_ah/login_required というURLに自動的にリダイレクトされるようになる。ただし、開発サーバではリダイレクト先は /_ah/login となり、おなじみのやる気のないログイン画面が表示されるので、開発サーバでOpenIDを使った認証のテストはできそうにない。URL直打ちで /_ah/login_required にアクセスしてログイン画面を表示したとしても、後述の理由により、OpenID認証のテストはやっぱりできない。

<security-constraint>
  <web-resource-collection>
    <url-pattern>/secret/*</url-pattern>
  </web-resource-collection>
  <auth-constraint>
    <role-name>*</role-name>
  </auth-constraint>
</security-constraint>

rolename には * を指定。つまり、OpenIDでログインしさえすれば誰でもこのページにアクセス可能ということ。rolename に指定可能なもう一つの値である admin を指定すると、管理画面の「Permissions」で管理権限を与えられた Googleアカウントを使ってログインしたときのみアクセス可能になる。それ以外の任意のOpenIDでもログイン自体はできるが、Forbidden的エラー画面が表示される。

ログイン画面(OpenIDプロバイダの選択画面)の表示

この /_ah/login_required にはデフォルトでどのような処理もマッピングされていないので、自分でサーブレットを作成し、このURLにマッピングしないといけない。サーブレットは何をすればよいのかというと、ログイン画面を表示すればよい。本来アクセスしようとしていたページのURLは continue というGETパラメータで渡されてくる。当然、ログインが無事完了したらこのURLにリダイレクトしたいわけだけど、そのためにはこのパラメータを次フェーズまで持ち越さないといけないので、ここでは再びGETパラメータとして次のコントローラに引き渡す。/_ah/login_required をURL直打ちでアクセスするとパラメータcontinueが存在しないことになるけど、その場合のことは次フェーズで考えるので、ここでは気にしない。

理解のために付け加えておくと、ここでログイン画面を表示するのは絶対的なルールではなくて、仮に、ユーザにOpenIDプロバイダを選ばせないのなら(OpenIDである意味がないけど)、ここでいきなりプロバイダの認証画面にリダイレクトしても問題はない。

public class LoginFormServlet extends HttpServlet {
  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse res)
      throws ServletException, IOException {
    req.setAttribute("continue", req.getParameter("continue"));
    req.getRequestDispatcher("/loginForm.jsp").forward(req, res);
  }
}
  <servlet>
    <servlet-name>LoginFormServlet</servlet-name>
    <servlet-class>package.to.LoginFormServlet</servlet-class>
  </servlet>
  <servlet-mapping>
    <servlet-name>LoginFormServlet</servlet-name>
    <url-pattern>/_ah/login_required</url-pattern>
  </servlet-mapping>

サーブレットを作る代わりにslim3のコントローラ生成で _ah/login_required に対応するコントローラを作ることはできない。作ることはできるのだけど、名前が _ah で始まるファイルがプロジェクトに含まれていると、デプロイ時に次のようなエラーがでる。

Filename cannot contain "." or ".." or start with "-" or "_ah/"

これらのルールに違反しない普通のファイル名、たとえば ah/loginRequired といった名前でコントローラを生成し、AppRouter を使ってこのコントローラを _ah/login_required にマッピングすることはできる。

public class LoginRequiredController extends Controller {
  @Override
  public Navigation run() throws Exception {
    return forward("loginRequired.jsp");
  }
}
public class AppRouter extends RouterImpl {
  public AppRouter() {
    addRouting("/_ah/login_required", "/ah/loginRequired");
  }
}

さて、せっかくログイン画面を表示するのだから、いくつかプロバイダを選択できるようにしておこう。このなかで、はてなだけはOP識別子にユーザのIDが含まれるので、ここではid:xfan固定にしているが、実際にはユーザにIDを入力させてjavascriptで送信パラメータをいじったうえで画面遷移する処理が必要になる。

<html>
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
  <title>LoginForm</title>
</head>
<body>
  <p><a href="/login?openid_identifier=https://www.google.com/accounts/o8/id&continue=${continue}">Googleでログイン</a></p>
  <p><a href="/login?openid_identifier=https://mixi.jp/&continue=${continue}">Mixiでログイン</a></p>
  <p><a href="/login?openid_identifier=http://www.yahoo.co.jp/&continue=${continue}">Yahooでログイン</a></p>
  <p><a href="/login?openid_identifier=http://livedoor.com/&continue=${continue}">Livedoorでログイン</a></p>
  <p><a href="/login?openid_identifierhttp://www.hatena.ne.jp/xfan/&continue=${continue}">はてな(id:xfan)でログイン</a></p>
</body>
</html>

OP(OpenID Provider)の認証画面にリダイレクトするコントローラ

前述のログイン画面でユーザが選んだOPの認証画面へリダイレクトするコントローラを作成する。サンプルコードはslim3コントローラで。

public class LoginController extends Controller {

  @Override
  public Navigation run() throws Exception {

    String openid_identifier = asString("openid_identifier");
    String _continue = asString("continue");
    if (StringUtils.isBlank(_continue)) {
      _continue = "/";
    }

    UserService userService = UserServiceFactory.getUserService();
    User currentUser = userService.getCurrentUser();
    if (currentUser != null) {
      return redirect(_continue);
    }

    Set<String> attributesRequest = new HashSet<String>();

    String loginUrl = userService.createLoginURL(
        _continue,
        request.getServerName(), 
        openid_identifier,
        attributesRequest);
	
    return redirect(loginUrl);
  }
}

コードを要約すると、UserService#createLoginURLメソッドで認証画面のURLを生成してそこにリダイレクトするということ。開発サーバ上では、createLoginURL メソッドが常に /_ah/login を返すようなので、開発サーバ上でのOpenID認証のテストはどうがんばってもここで挫折するのであった。

アプリ側で必要なコードは以上。あとはユーザがログインを承認すれば保護されているページにアクセスできる。

createLoginURLメソッドの最後の引数attributesRequestには何かを指定する必要があるのか、よくわからない。ネットで検索すると、

  Set<String> attributesRequest = Sets.newHashSet(
      "openid.mode=checkid_immediate",
      "openid.ns=http://specs.openid.net/auth/2.0",
      "openid.return_to=" + _continue);

のような値を設定しているのを見かけるけど、これで何か振る舞いが変化しているのかはちょっと不明。なくても動く。

openid.modeは、checkid_immediateに設定しても、createLoginURLで生成されたURL上ではcheckid_setupになっているし、特に最後の openid.return_to パラメータの指定は、確実に機能していない。プロバイダはユーザからの承認を受け取ったあと return_to で指定されたアプリ側のURLにリダイレクトし、アプリはそこで認証が成功したかどうか判断して成功していたらログイン処理を実行するのだけど、GAEのOpenID認証では、この成否チェックの部分をGAEが受け持っている。具体的には /_ah/openid_verify という return_to 値が createLoginURL時に自動的に設定されていて、/_ah/openid_verify のなかでGAEが認証の成否チェックをし、成功ならUserServiceのcurrentUserを設定している。/_ah/openid_verify 実行後のリダイレクト先を決めるのは return_to ではなくて、createLoginURLの第一引数のほう。

ATNDみたいにOpenIDTwitter認証を並べて使いたいんだけど

GAEはTwitter認証などサポートしていないのであるので、Twitterで認証に成功してもGAEにUserオブジェクトを生成させる方法は多分ないし(あったら教えて><)、つまり web.xml の security-constraint を使ったページ保護もつかえない。

なので、OpenIDの認証画面URL生成と認証成否チェックはGAEが提供しているものを使いまわすが、ログイン処理はセッションを使って自前で実装し、ログインチェックもFilterを使って自前で構築することにする。Twitter認証はTwitter4Jを使う。下記の図が処理フローのイメージ。結構いいかげんな図であり、破線矢印は間にリダイレクトが省略されたりしているので注意。太い赤線で囲まれている部分が、自前で作成しないといけない部分。他にログインチェック用フィルタも作成が必要。


コントローラ名 パラメータ 概要
LoginForm continue 認証サービスプロバイダ選択画面を表示するだけ
OpenIdLogin openid_identifier,continue OPの認証画面へリダイレクト
TwitterLogin continue Twitterの認証画面へリダイレクト
/_ah/openid_verify OPがいろいろ送ってくる GAE提供。OpenID認証の成否チェックとUserオブジェクト生成。パラメータを使ってLoginコントローラにリダイレクト
TwitterVerify continue, oauth_token,oauth_verifier Twitter認証の成否チェックして、Loginコントローラにリダイレクト
Login type,continue OpenID,Twitterに共通な自前ログイン処理を行う

フィルタを使ってページの保護

まずは、web.xmlの security-constraint をあきらめて、ログインチェックを自前で実装。

public class LoginCheckFilter implements Filter {
  @Override
  public void doFilter(ServletRequest req, ServletResponse res,
      FilterChain chain) throws IOException, ServletException {

    HttpServletRequest hreq = (HttpServletRequest) req;
    HttpServletResponse hres = (HttpServletResponse) res;

    if (isLoggedIn(hreq)) {
      chain.doFilter(req, res);
    } else {
      String _continue = hreq.getRequestURL().toString();
      hres.sendRedirect("/loginForm?continue=" + _continue);
    }
  }

  private boolean isLoggedIn(HttpServletRequest hreq) {
    // TODO ログイン処理が完成してから実装
    return false;
  }

  @Override
  public void init(FilterConfig conf) throws ServletException {
  }
  @Override
  public void destroy() {
  }
}

web.xml は、security-constraint要素によるアクセス制御を削除して、フィルタインスタンスとそのマッピングを定義する。

<filter>
  <filter-name>LoginCheckFilter</filter-name>
  <filter-class>com.kushitama.core.LoginCheckFilter</filter-class>
</filter>
<filter-mapping>
  <filter-name>LoginCheckFilter</filter-name>
  <url-pattern>/secret/*</url-pattern>
  <dispatcher>REQUEST</dispatcher>
</filter-mapping>

これで、URLが /secret/ ではじまるページへのアクセスは保護され、ログインしていないユーザは /loginForm にリダイレクトされる。/loginForm には、ユーザが本来アクセスしようとしていたページのURLは、パラメータ名 continue で渡される。このパラメータは一連のログイン処理が終わるまで(コントローラを4つほど経由するが)、ずーっとパラメータとして渡し続ける。それがめんどくさいなら(本当にめんどくさい)セッションにいれてしまってもいいと思う。

認証サービスプロバイダ選択画面表示

ログインチェックで未ログインと判定されたユーザが飛ばされる先、認証サービスプロバイダの選択画面をつくる。受け取ったパラメータ continue は、次のコントローラに受け渡すため、リンク先URLにクエリ文字列としてくっつけておく。ここではOpenIDTwitterでコントローラを分けるので、Twitterだけリンク先URLが異なるのに注意。

public class LoginFormController extends Controller {
    @Override
    public Navigation run() throws Exception {
        return forward("loginForm.jsp");
    }
}
<html>
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
  <title>LoginForm</title>
</head>
<body>
  <p><a href="/openIdLogin?openid_identifier=https://www.google.com/accounts/o8/id&continue=${continue}">Googleでログイン</a></p>
  <p><a href="/openIdLogin?openid_identifier=https://mixi.jp/&continue=${continue}">Mixiでログイン</a></p>
  <p><a href="/openIdLogin?openid_identifier=http://www.yahoo.co.jp/&continue=${continue}">Yahooでログイン</a></p>
  <p><a href="/openIdLogin?openid_identifier=http://livedoor.com/&continue=${continue}">Livedoorでログイン</a></p>
  <p><a href="/openIdLogin?openid_identifier=http://www.hatena.ne.jp/xfan/&continue=${continue}">はてな(id:xfan)でログイン</a></p>
  <p><a href="/twitterLogin?continue=${continue}">Twitterでログイン</a></p>
</body>
</html>

OpenIDプロバイダの認証画面にリダイレクトするコントローラ

ここは、GAEのOpenID認証のみをつかった場合とほぼ同じ。違う点は、UserService#getCurrentUser() でUserがすでに存在していた場合および /_ah/openid_verify が実行された後のリダイレクト先。今回は、最後のログイン処理をおこなうコントローラへリダイレクトしている。そして、/_ah/openid_verify 実行後のリダイレクト先にクエリ文字列でパラメータが2つ以上くっついている場合、つまりURLに"&"が含まれている場合は、URLエンコードが二重に必要になる。

createLoginUrlメソッドの第一引数は /_ah/openid_verify の continue パラメータの値になり、さらに /_ah/openid_verify?continue=[createLoginUrlの第一引数] というURL自体が認証プロバイダに送信する openid.return_to パラメータの値になるのだから仕方のないような気もするけど、二度目のURLエンコードはGAEが内部でやるべきことのような気もする。

とにかく自分で二度エンコードしないと、/_ah/openid_verify が受け取るパラメータが[createLoginUrlの第一引数]の中に含まれている"&"で分割されてしまうのであった。

public class OpenIdLoginController extends Controller {
  @Override
  public Navigation run() throws Exception {

    String openid_identifier = asString("openid_identifier");
    String _continue = asString("continue");
    if (StringUtils.isBlank(_continue)) {
      _continue = "/";
    }

    UserService userService = UserServiceFactory.getUserService();
    User currentUser = userService.getCurrentUser();
    if (currentUser != null) {
      return redirect("/login?type=openid&continue=" + _continue);
    }

    String loginUrl = "/login?"
        + URLEncoder.encode(URLEncoder.encode("type=openid&continue="
            + _continue, "utf-8"), "utf-8");

    String providerLoginUrl = userService.createLoginURL(
        loginUrl,
        request.getServerName(),
        openid_identifier,
        new HashSet<String>());

    return redirect(providerLoginUrl);
  }
}

Twitterの認証画面にリダイレクトするコントローラ

Twitter4Jをつかう。バージョンは 2.2.0。CONSUMER_KEY と CONSUMER_SECRET は二度使うので、別クラスに定数定義しておいた。別にどこに書いてもいい。

public class TwitterService {
  public static final String CONSUMER_KEY = "...";
  public static final String CONSUMER_SECRET = ".......";
}

リクエストークンを生成した上で、認証画面のURLを取得してそこにリダイレクトする。リクエストークンは、認証完了時の検証に使うので、サーバ側でセッションに保管しておく。認証後、TwitterからアプリのどのURLにリダイレクトさせるかもここで決めておく。ここでは /twitterVerify というURLを利用。ログイン完了後に表示しなければならないページのURLである continue をパラメータとして忘れずくっつけておく。

Twitterからのリダイレクト先がlocalhostだとうまくいかないという話をこちら(参考にさせてもらいました)で読んだけど、自分でやったらローカルでもうまくいったりしたのだった。前述のとおり、GAEのOpenID認証はローカル環境ではテストできないので、全体的なログインフローの動作確認はTwitter認証のほうを使ってやればいいと思う。

public class TwitterLoginController extends Controller {
  @Override
  public Navigation run() throws Exception {

    String _continue = asString("continue");
    if (StringUtils.isBlank(_continue)) {
      _continue = "/";
    }

    Twitter twitter = new TwitterFactory().getInstance();
    twitter.setOAuthConsumer(CONSUMER_KEY, CONSUMER_SECRET);

    RequestToken requestToken = twitter
        .getOAuthRequestToken(getCallbackUrl(_continue));
    sessionScope("requestToken", requestToken);

    return redirect(requestToken.getAuthorizationURL());
  }

  private String getCallbackUrl(String _continue) {
    StringBuffer url = request.getRequestURL();
    url.delete(url.lastIndexOf(basePath), url.length());
    url.append("/twitterVerify?continue=").append(_continue);
    return url.toString();
  }
}

OpenIDおよびTwitterから認証結果を受け取る

OpenIDのほうはGAEによって自動的に return_to に指定されたURL /_ah/openid_verify にリダイレクトされ、そこで認証結果の検証が行なわれたあと、createLoginURLの第一引数に渡したURLにリダイレクトされる。ので、自分で何か実装する必要はない。Twitterの認証結果を検証するコントローラのみ自作する。

ログイン処理は、ログイン失敗時の処理も含めて最後のLoginControllerで行うので、TwitterExceptionは発生してもスルーしておく。エラーが発生したかどうかは、LoginControllerではセッションにAccessTokenオブジェクトが格納されているかどうかで確認できる。

public class TwitterVerifyController extends Controller {
  @Override
  public Navigation run() {

    Twitter twitter = new TwitterFactory().getInstance();
    twitter.setOAuthConsumer(CONSUMER_KEY, CONSUMER_SECRET);

    String _continue = asString("continue");
    if (StringUtils.isBlank(_continue)) {
      _continue = "/";
    }

    RequestToken requestToken = removeSessionScope("requestToken");
    String verifier = asString("oauth_verifier");

    AccessToken accessToken = null;
    try {
      accessToken = twitter.getOAuthAccessToken(requestToken, verifier);
      sessionScope("accessToken", accessToken);
    } catch (TwitterException e) {
      System.out.println(e);
      // するー
    }
    return redirect("/login?type=twitter&continue=" + _continue);
  }
}

ログイン処理と、それに合わせてログインチェック処理の実装

OpenID認証を行った場合は、GAEによってUserService#getCurrentUser()が設定された状態で、またTwitter認証を行った場合は、自前のTwitterVerifyControllerによってセッションにAccessTokenが格納された状態で、最後のログイン処理用コントローラLoginControllerが呼び出される。

LoginControllerには、さらに2つのGETパラメータも渡されている。ひとつはtypeで、これは"openid"か"twitter"かどちらかの値をとり、ユーザがどちらの方法で認証を行ったかを判別する。UserService#getCurrentUser()がnullでなければOpenID認証が選ばれたとみなす、などとすることもできるので、別になくてもいいのだけど。もうひとつのパラメータはcontinueで、この長いログインプロセスの前に、ユーザがそもそもアクセスしたかったページのURLがはいっている。ようやく、ここで利用する。

public class LoginController extends Controller {
  @Override
  public Navigation run() throws Exception {

    String userId = null;

    String type = asString("type");
    if ("openid".equals(type)) {
      UserService userService = UserServiceFactory.getUserService();
      User user = userService.getCurrentUser();
      if (user == null) {
        return forward("/loginError.jsp");
      }
      userId = user.getUserId();
    } else if ("twitter".equals(type)) {
      AccessToken accessToken = sessionScope("accessToken");
      if (accessToken == null) {
        return forward("/loginError.jsp");
      }
      userId = accessToken.getScreenName();
    }

    sessionScope("userId", userId);

    String _continue = asString("continue");
    if (StringUtils.isBlank(_continue)) {
      _continue = "/";
    }
    return redirect(_continue);
  }
}

セッションに "userId" というキーでユーザIDを格納することをもってログインとしたので、それにあわせてLoginCheckFilterを実装しておく。

private boolean isLoggedIn(HttpServletRequest hreq) {
  HttpSession sess = hreq.getSession();
  return (sess != null && sess.getAttribute("userId") != null);
}


これで完成。つかれたあ。