パスワードを適切に管理したサンプルシステム(その2)


パスワードを適切に管理したサンプルシステム(その1)の続き。

ちゃんとパスワードを管理したシステムを作りたい!ということがテーマのこのエントリ、今回はパスワードリセットについてです。パスワードリセット方式は徳丸さんの以下のエントリが非常に勉強になります。というかセキュリティの個人blogはおそらくこの人が日本ではTOPだと思う。

リセット後のパスワードをメール送信するパスワードリセット方式の注意点

http://blog.tokumaru.org/2013/05/how-to-make-your-password-reset-strong.html

パスワードリセットはリセット用のURLをメールで送信する形にします。そこで徳丸さんのエントリにもあるようにメールは盗聴される前提で考えなければならない。今回もセキュリティ要件をまとめてみます。

セキュリティ要件

  1. パスワードリセットはWebのフォームから依頼する形式とする。
  2. パスワードリセットを行うときは、アカウントID、メールアドレスを入力させ、情報が合致するアカウントが存在した場合のみリセット処理を続行する。
  3. リセット処理ではパスワード再設定用のURLを作成し、それをメールで通知する。(実際にDB上のパスワードをリセットするわけではない。)
  4. 再設定用のURLでは、新パスワードと新パスワード(確認)を入力させる。
  5. パスワードの再設定が完了した場合、それをメールで通知する。
  6. 同じURLでパスワード再設定は1度限り可能とする。(再設定完了後はURLを無効化する)
  7. URLは発行から1時間のみ有効とする。

パスワードリセットを依頼するWebのフォーム(要件1, 2)

本当は秘密のパスワード系も入力させるべきだと思うけど、今回は不採用とします。アカウントとメールアドレスは公開されていることが多いし流用も多いので、この2つだけだと簡単にリセットされてしまい、迷惑行為が成立してしまいます。これに対する策は以下。

リセット処理、URL作成とメール通知(要件3)

URLを作成し、メールで通知します。このときDB上のパスワード列は変更しません。これなら例え第三者にパスワードリセット処理を実行されてしまったとしても、自分のメアドにメールが届くだけで、今までのパスワードでログインは続行できます。メールは無視すればOKです。

再設定のWebフォーム(要件4)

ユーザはメールのURLをクリックして、パスワードリセットページへ飛びます。ここで第三者にメールを盗聴され、リセット用のURLを把握されていたら?第三者にURLに先にアクセスされ、新パスワードを設定され、ログインされてしまいます。メールを盗聴するくらい用意周到なハッカーならおそらくユーザIDも既に把握しているだろうし、ユーザID+自分で設定した新パスワードを利用できてしまいます。これに対しては要件2の部分で秘密の質問を入れるしかないように思います。

再設定処理、完了通知とURL無効化(要件5, 6, 7)

意図しないパスワード変更に対する保険です。覚えのないメールが来たら何かしらアクションを起こせます。リセット用URLに制限を持たせるのも同様の保険です。

実装

様々な実装方法があるだろうけど、自分は以下のように考えてみました。

まずパスワードリセット用のURLが固定だったり規則性があると簡単に悪用されてしまうので、URLにはランダムな文字列を採用することにします。例えば以下のような。

http://tsukaby.com/LoginSystem/PasswordReRegister/?key=kdhfwElahsldfELKFhp1

上記のハッシュとリセットするユーザを結びつける必要があるため、新たにテーブルを用意します。

-- パスワードリセット
CREATE TABLE PASSWORD_RESET
(
    ACCOUNT_ID VARCHAR(50) NOT NULL,
    PART_OF_URL VARCHAR(128) NOT NULL UNIQUE,
    EXPIRE_DATE DATETIME NOT NULL,
    PRIMARY KEY (ACCOUNT_ID)
) COMMENT = 'パスワードリセット';

パスワードリセットを依頼する画面でアカウントとメールの認証が成功した場合は、このテーブルにデータを格納します。その後、メールを送るコードは以下のような感じ。普通にJavaMailを使うだけ。

メールのテンプレートシステムにはJakarta Velocityを採用します。他に良いの知ってないし、使い勝手は良い方だと思う。

メールのテストにはFakeSMTPが良いと思います。localhostに仮のSMTPサーバを立てて、そこへ流れてくるメールをトラップしてくれます。

まずメールのテンプレートとなるpassword_reset.vmを作成します。

${name} 様

以下のURLからパスワードを再登録してください。

${url}

${webmasterMail}

次に画面側でIDとメールをPOSTした後の処理を作成します。URLの乱数部分、以下だとpartOfUrlはRandomStringUtils.randomAlphanumeric(64);などとして適当に作成します。引数のaccountIdは予め入力されたIDとメールでDB検索した結果を利用します。

  @Transactional
  public void registerPasswordReset(String accountId, String partOfUrl) throws UserRegistrationServiceException {
    Calendar expireDate = Calendar.getInstance();
    expireDate.add(Calendar.MINUTE, 60);

    PasswordReset record = new PasswordReset(accountId, partOfUrl, expireDate.getTime());
    passwordResetMapper.insert(record);
  }

insertが成功したら次はそれをメール送信。例外処理が適当だけどまじめにやるとかなり肥大化しそうなので、省略します。

  public void sendPasswordResetMail(String name, String mailToAddress, String url) {
    // メールセッションを確立
    Session session = Session.getDefaultInstance(getMailProperty(), null);
    // 送信メッセージを生成
    MimeMessage objMsg = new MimeMessage(session);
    try {
      // 送信先(TOのほか、CCやBCCも設定可能)
      objMsg.setRecipients(Message.RecipientType.TO, mailToAddress);
      // Fromヘッダ
      InternetAddress objFrm = new InternetAddress(mailFromAddress, mailFromName);

      objMsg.setFrom(objFrm);

      Configuration config = new PropertiesConfiguration("mail.properties");
      // 件名
      String title = config.getString("mail_password_reset_title");
      objMsg.setSubject(title, "UTF-8");

      // 本文
      StringWriter sw = new StringWriter();
      VelocityContext context = new VelocityContext();
      context.put("name", name);
      context.put("url", url);
      context.put("webmasterMail", mailFromAddress);
      Template template = Velocity.getTemplate("mail/template/password_reset.vm", "UTF-8");
      template.merge(context, sw);
      objMsg.setContent(sw.toString(), "text/html;charset=UTF-8");

      // メール送信
      Transport.send(objMsg);
    } catch (UnsupportedEncodingException e) {
      e.printStackTrace();
    } catch (MessagingException e) {
      e.printStackTrace();
    } catch (ConfigurationException e) {
      e.printStackTrace();
    }
  }

上記の引数urlは本当は良い求めかたがあるのだろうけど、まだ解明できていないのでとりあえずドメイン部分は決め打ち。

        String url = "http://tsukaby.com/LoginSystem/" + "PasswordReRegisterPage" + "?key="
            + partOfUrl;

最後にメール上のURLでアクセスされるリセット用のページを作成します。このページはWicketで普通に作成します。上記でkey=としたので、それを受け取れるようWebPageクラスのコンストラクタ内で以下を記述します。

  public PasswordReRegisterPage(PageParameters parameters) {
    super(parameters);

    final StringValue partOfUrl = parameters.get("key");
    ...

後は今までとほぼ同じです。画面上で新パスワード、新パスワード(確認)をPOSTされたら、再設定処理を行います。もちろんパスワードはsaltと合わせてハッシュ化し登録します。登録が完了したらまた、上記のメール処理と同様にpassword_reset_complete.vmをVelocityでこねこねして送信します。

key=の部分を好きに変えることでブルートフォース攻撃される気もしますが、多分それほど問題ではないと思います。まず基本オンラインでは遅すぎてブルートフォースアタックは向きません。やるやつが居たとしても、それはDoS攻撃なのでFWでブロックできます。勿論DDosだったりすると話は簡単ではないですが。そして、FWが無かったとして、攻撃可能な状態にあったとしてもアカウントIDは不明なので攻撃者にうま味がそれほどないです。key=の部分を変更してもページは普通に表示できるし、色々攻撃しづらいと思います。

うま味がなくても攻撃はあるとか、レイヤ7のFWは性能に影響があるから云々とか色々あるけど、今回はあくまでパスワードがメインテーマなのでこの話はまたいつか別に取り上げたいと思います。

そんな訳で、今回はパスワードリセット方式を検討し実装してみました。次回は今回作成しているLoginSystemプログラムの気になる点を修正して公開します。