Javaのログイン認証とは?データベースのユーザー情報と照合する流れ

最終更新日

Schoo Java入門 中級 第8回 Webアプリケーションにおける状態管理

ログイン認証で行うのは、入力されたIDを保存することではなく、その利用者としてログインしてよいかを確かめることです。IDがDBに存在するだけでは、パスワードを知らない人までログインできてしまいます。

そこで、IDをもとに登録済みのユーザー情報を取得し、パスワードを照合します。成功した場合だけ、確認済みの利用者情報をセッションに保存します。この記事では、フォームの入力・DBの検索・本人確認・セッションへの保存を、Servlet・Model・DAOの役割に沿ってつなげます。

本記事は、Schoo「Java入門 中級」第8回「Webアプリケーションにおける状態管理」の後半に対応しています。授業の受講や配布プロジェクトを前提にせず、ID「1001」の利用者がログインする例で説明します。

認証は「誰として利用してよいか」の確認、セッションはその結果の保存先

処理今回の例で確認・保存すること
ログイン認証入力されたIDとパスワードで、その利用者としてログインしてよいかを確かめる。
セッションへの保存認証に成功した利用者のID・名前を保存し、次のリクエストでも取り出せるようにする。

セッションが作られたからといって、認証に成功したことにはなりません。認証に成功した後で保存した情報があるかを、アプリが確認します。ログイン後の画面表示やログアウトは、Javaのログイン・ログアウトの仕組みとは?セッションで利用者を管理する方法で扱っています。今回は「成功した利用者情報を得るまで」が中心です。

図解:Servletから判定を依頼し、確認済みの利用者情報を受け取る

担当行うこと
LoginServlet入力を受け取る。Modelへ判定を依頼する。結果に応じてセッション保存や画面遷移を行う。
LoginModel入力の確認とパスワードの照合を行い、ログインの可否を判断する。アプリの判断・処理を担当するModelの一つ。
LoginDaoSQLを実行し、DBの検索結果をJavaのオブジェクトにして返す。画面遷移やセッション保存はしない。
JSPなどの表示側Servletから渡された表示用データを使う。SQLを実行したり、パスワードの正否を判定したりしない。

この分け方なら、画面の入力欄を直す場合はServlet周辺、検索する列を直す場合はDAO、ログインの判定ルールを直す場合はModel、というように確認する場所を絞れます。Modelの判定は、ブラウザを開かずにテストすることもできます。

まず、入力とDBのデータを具体的に並べてみる

今回の例では、DBのusersテーブルに次の利用者が登録されているとします。idは重複しない正の整数です。名前をログインフォームから送る必要はありません。成功後に使う名前はDBから取得します。

idnamepassword_hash
1001田中登録時に作成した照合用ハッシュ
1002佐藤別の利用者の照合用ハッシュ

password_hashには、パスワードそのものを保存しません。登録時にパスワード専用の処理で作った「照合用ハッシュ」を保存します。ログイン時は、入力されたパスワードがそのハッシュに対応するかを調べます。ハッシュから元のパスワードを取り出して比較する、という流れではありません。

入力例期待する結果
ID 1001 + 登録時と同じパスワード田中さんとして認証成功。DBで確認したID・名前を返す。
ID 1001 + 違うパスワードIDの行は見つかっても、認証は失敗。
未登録のID 9999対象の行がないため、認証は失敗。
ID abc今回の整数IDとして扱えない。DBの検索へ進めない。

以降のコードは、処理のつながりを読むための抜粋です。Servletを実行するWebアプリ、JDBCの接続設定、登録済みのusersテーブルがある前提で、環境構築やユーザー登録の実装は扱いません。専用の授業ファイルを取得しなくても、入力例と期待結果を追える構成にしています。

1.Servletは文字列として入力を受け取り、Modelへ渡す

login-form.htmlのフォーム部分:POSTで/loginへ送る

<form action="login" method="post">
    <label>ID <input name="userId" required></label>
    <label>パスワード <input type="password" name="password" required></label>
    <button type="submit">ログイン</button>
</form>

このフォームをアプリの公開フォルダー直下に置くと、action=”login”は同じアプリの/loginを指します。Servlet側はそのURLを担当する設定にします。name="userId"とname="password"が、getParameterで受け取る名前です。

IDに1001と入力しても、request.getParameter("userId")で取得するのは文字列です。Servletは、この文字列とパスワードをModelへ渡します。requiredはブラウザの入力補助であり、サーバー側の確認を省略する理由にはなりません。

LoginServlet内:LoginModelの判定結果を受け取る

LoginUser loginUser = model.login(idText, password);

modelはLoginModelのオブジェクト、idTextとpasswordは入力文字列です。loginUserには「入力したID」ではなく、「Modelが認証に成功したと判断した利用者情報」が返ります。入力不備や認証失敗の場合は、この例ではnullを返す設計です。

2.DAOはIDで1件検索する。見つかっただけではログイン成功ではない

LoginDaoのfindByIdは、指定されたIDのユーザー情報を探します。dbUrlはDAOのフィールドに設定済みのJDBC接続先文字列です。フォームの値を接続先として使うわけではありません。

LoginDao.javaのfindByIdメソッド:IDに対応する1行を取得

public UserAccount findById(int userId) throws SQLException {
    String sql = "SELECT id, name, password_hash FROM users WHERE id = ?";

    try (Connection con = DriverManager.getConnection(dbUrl);
         PreparedStatement ps = con.prepareStatement(sql)) {
        ps.setInt(1, userId);

        try (ResultSet rs = ps.executeQuery()) {
            if (!rs.next()) {
                return null;
            }
            return new UserAccount(
                rs.getInt("id"),
                rs.getString("name"),
                rs.getString("password_hash")
            );
        }
    }
}

UserAccountは、id・name・passwordHashを持つ検索結果用のクラスです。コンストラクタでこの3つを受け取り、getId・getName・getPasswordHashで取り出せるものとします。ここではその定型的なフィールド・getterの記述は省略しています。

  • WHERE id = ?:検索するIDを値として渡す。setInt(1, userId)の1は、最初の?の番号。
  • rs.next()がfalse:該当する利用者がいないのでnullを返す。
  • rs.next()がtrue:見つかった行からUserAccountを作って返す。
  • SQLException:DBの検索自体に失敗したことを、呼び出し元へ例外として伝える。

このSQLにパスワードの文字列を連結しません。まずIDで照合用の情報を取得し、次のModelで専用ライブラリを使ってパスワードを確認します。try-with-resourcesにより、検索結果・SQL実行用の部品・接続は使い終わったら閉じられます。

3.Modelが入力とパスワードを確認して、利用者情報を返す

次はLoginModelのloginメソッドです。daoは、接続先を設定したLoginDaoを保持するフィールドです。Argon2FunctionはJava標準のクラスではなく、パスワード照合用ライブラリPassword4jのcom.password4j.Argon2Functionです。この例では、DBにArgon2id方式で登録済みのハッシュがあるものとし、その照合処理だけを呼び出します。

LoginModel.javaのloginメソッド:成功した場合だけLoginUserを返す

public LoginUser login(String idText, String password) throws SQLException {
    if (idText == null || password == null || password.isEmpty()) {
        return null;
    }

    int userId;
    try {
        userId = Integer.parseInt(idText);
    } catch (NumberFormatException e) {
        return null;
    }
    if (userId <= 0) {
        return null;
    }

    UserAccount account = dao.findById(userId);
    if (account == null) {
        return null;
    }

    String hash = account.getPasswordHash();
    Argon2Function verifier = Argon2Function.getInstanceFromHash(hash);
    boolean matched = verifier.check(password, hash);
    if (!matched) {
        return null;
    }

    return new LoginUser(account.getId(), account.getName());
}
  1. nullや空のパスワードを確認する。
  2. 文字列のIDをintに変換する。変換できない入力や、0以下のIDは受け付けない。
  3. DAOにIDの検索を依頼する。見つからなければnullを返す。
  4. 見つかった利用者のハッシュと、入力されたパスワードを照合する。
  5. 一致したときだけ、DBのID・名前を使ってLoginUserを作る。

getInstanceFromHash(hash)は、保存されたハッシュに記録されている設定に対応する照合用オブジェクトを取得します。verifier.check(password, hash)の結果はbooleanで、照合に成功したらtrue、不一致ならfalseです。ここで注目したいのは暗号処理の内部ではなく、検索の成功と、パスワード照合の成功を、別の条件として確認していることです。今回のコード検証にはPassword4j 1.8.4を使用しています。

パスワードの前後の空白を勝手に削除すると、入力された値が別の値になります。そのため、この例ではpasswordをtrimしていません。登録時と同じルールで、そのまま照合します。なお、ライブラリを使う場合も、登録時には適切な計算コストと利用者ごとに異なるランダムなソルトを用いてハッシュを作る必要があります。

また、ここでcatchしているのはID変換のNumberFormatExceptionだけです。SQLExceptionをnullへ置き換えないことで、「利用者の情報が合わなかった」のか「DBを調べられなかった」のかを呼び出し元が区別できます。

4.セッションへ渡すLoginUserには、IDと名前だけを持たせる

LoginUser.java:認証に成功した利用者を表すクラス

public class LoginUser {
    private final int id;
    private final String name;

    public LoginUser(int id, String name) {
        this.id = id;
        this.name = name;
    }

    public int getId() {
        return id;
    }

    public String getName() {
        return name;
    }
}
オブジェクト持つ情報と用途
UserAccountID・名前・パスワードハッシュ。DAOからModelへ返し、認証の照合に使う。セッションには入れない。
LoginUserID・名前。認証成功後にModelからServletへ返す。パスワードやハッシュを含めない。

名前が似ていても、用途は違います。DBから取った行を、何でもそのままセッションへ入れないことがポイントです。LoginUserはデータをまとめて受け渡すためのクラスで、メソッド名と型を見るだけでも「認証成功後に渡す情報」だと読み取りやすくなります。

ただし、LoginUserをnewしただけで本当に認証済みになるわけではありません。今回の処理が、照合に成功した分岐でだけ生成して保存するから意味があります。名前の付け方は、正しい処理順の代わりにはなりません。

5.Servletは結果を受け取り、成功した場合だけセッションへ保存する

次はLoginServletのdoPost部分です。modelは初期化済みのLoginModelです。/login-error.htmlは「IDまたはパスワードを確認してください」と表示するページ、/mypageはログイン状態を確認してから表示するServletのURLとして扱います。

LoginServlet.javaのdoPostメソッド:判定結果に応じて処理を分ける

protected void doPost(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException {
    request.setCharacterEncoding("UTF-8");
    response.setHeader("Cache-Control", "no-store");
    String idText = request.getParameter("userId");
    String password = request.getParameter("password");

    HttpSession oldSession = request.getSession(false);
    if (oldSession != null) {
        oldSession.invalidate();
    }

    try {
        LoginUser loginUser = model.login(idText, password);
        if (loginUser == null) {
            response.sendRedirect(request.getContextPath() + "/login-error.html");
            return;
        }

        HttpSession session = request.getSession();
        session.setMaxInactiveInterval(30 * 60);
        session.setAttribute("loginUser", loginUser);
        response.sendRedirect(request.getContextPath() + "/mypage");
    } catch (SQLException e) {
        getServletContext().log("ログイン処理でDBアクセスに失敗しました。");
        response.sendError(503, "現在ログインできません。時間をおいてお試しください。");
    }
}

この例では新しいログインを試みるときに、以前のセッションを無効にする方針です。入力ミスやDB障害が起きた後、以前の利用者としてログインしたままにはしません。認証成功後は新しいセッションになるため、ログイン前のセッションIDも引き継ぎません。カートなど別の情報を引き継ぐ必要があるアプリでは、残す情報を別途設計します。

Modelからの結果Servletの動き
LoginUserが返るloginUserという名前でセッションに保存し、/mypageへリダイレクトする。
nullが返る失敗ページへリダイレクトしてreturnする。今回のログイン情報は保存しない。
SQLExceptionが伝わるサーバー側にDB障害を記録し、503の案内を返す。認証成功として先へ進めない。

setAttributeの第1引数"loginUser"は、保存した情報を取り出すための名前です。第2引数のloginUserは、Modelから返されたLoginUserオブジェクトです。後のリクエストでは、有効なセッションがあることを確認したうえで、次のように取り出します。

マイページ側:有効なsessionを取得・確認した後の1行

LoginUser loginUser = (LoginUser) session.getAttribute("loginUser");

取得結果がnullなら未ログインとして表示を止めます。既存のログイン・ログアウト記事ではInteger型のloginUserIdを保存しましたが、今回の例はLoginUser型のloginUserです。保存側と取得側で、属性名も型も統一する必要があります。

図解:認証失敗とDB障害を、同じnullで隠さない

DB障害をcatchしてnullに変えてしまうと、正しいパスワードを何度入力しても「入力を確認してください」と表示されます。利用者は直しようがなく、運用側も障害を見逃しやすくなります。処理できなかった状態を、照合して不一致だった状態に見せないようにします。

一方、IDが未登録の場合とパスワードが違う場合は、利用者向けに同じ失敗メッセージを出しています。「このIDは登録されています」と外部から調べられる手掛かりを減らすためです。内部では失敗理由を整理しつつ、画面に出す情報は別に決めます。

入力・期待結果・セッションを組にして確認する

テストでは、戻り値だけでなく「ログイン情報が保存されたか」も確認します。以下は今回の実装に対応するケースです。「正しいパスワード」は、対象の利用者を登録したときのパスワードを指します。

入力・状態Modelの結果/その後の動き
1001・正しいパスワードID 1001のLoginUser。ServletがloginUserを保存し、/mypageへ移動する。
1001・違うパスワードnull。失敗画面へ進み、loginUserを保存しない。
9999・任意のパスワードnull。未登録であることを詳しく表示せず、同じ失敗画面へ進む。
abc・任意のパスワードnull。ID変換で止まり、DAOの検索を呼ばない。
IDが0・パスワードが空・パラメータが欠落null。今回の入力条件を満たさず、DAOの検索を呼ばない。
1001・正しいパスワードだがDBの検索に失敗SQLException。Servletは503を返し、loginUserを保存しない。
ログイン済みの状態から、再ログインに失敗先に旧セッションを無効にするため、以前のログイン情報も使えなくなる。

SQLの検索条件に入れるIDはPreparedStatementで渡し、パスワード・ハッシュ・例外の詳細を画面に返しません。ログイン後にできる操作は別途確認します。認証に成功したことと、他の利用者のデータを編集できることは別です。

公開するログイン機能では、認証の流れに加えて保護が必要

この記事は各クラスの役割とデータの流れを読むための例で、公開用の認証機能一式ではありません。実際に公開する際には、HTTPS、試行回数の制限、ログインCSRF対策、セッション用Cookieの属性、アカウントの停止・復旧なども設計します。POSTやpassword型の入力欄だけで安全になるわけではありません。

また、失敗メッセージを同じにしても、利用者の有無によって処理時間が大きく違えば手掛かりになります。この抜粋は未登録なら照合前にreturnするため、時間差への対策を含んでいません。公開用では既存の認証機構やライブラリを活用し、こうした対策も含めて検証します。パスワード照合の内部処理を独自に作ることは、この例の目的ではありません。

確認問題

  1. ID 1001の行がDBに見つかりました。この時点でLoginUserをセッションへ保存してよいですか。まだ必要な確認と、その担当を答えてください。
  2. 入力されたIDがabcの場合と、正しいIDでもDBの検索に失敗した場合は、ModelからServletへどのように伝わりますか。両方をnullにしない理由も答えてください。
  3. UserAccountをそのままセッションへ保存せず、LoginUserを作って返す理由を説明してください。それぞれが持つ情報も答えてください。
  4. 保存側がsession.setAttribute("loginUser", loginUser)なのに、取得側を(Integer) session.getAttribute("loginUserId")と書きました。何が起き、どのように直しますか。
  5. 今回のLoginServletで、既にログインしている利用者が誤ったパスワードで再ログインすると、以前のログイン状態は残りますか。処理の順序に沿って説明してください。

演習後の確認:こちらのページを使って、自分で考えた内容を見直せます。

まとめ

  • 入力されたIDを保存する前に、DBのユーザー情報とパスワードを照合する。
  • Servletは受け取りと画面遷移、Modelはログインの判定、DAOはDBの検索を担当する。
  • DBの検索結果と、認証成功後に渡すLoginUserを区別する。
  • セッションへ保存するのは確認済みの利用者情報。パスワードやハッシュは入れない。
  • 入力不備・認証失敗のnullと、DB障害のSQLExceptionを区別する。

関連記事

シェアする