ログインのREST API

ログインに使用するREST APIを実装します。

ログイン機能を実装するためには、ログイン状態を何らかの仕組みで保持する必要があります。これを実現するために、Nablarchのセッションストアを使用します。(参考:セッションストア | Nablarch

また、NablarchではユーザーIDを設定することで、スレッドコンテキスト機能やログ出力機能等で使用することができます。(参考:ユーザーIDを設定する | Nablarch

ここでは、ログイン中のユーザーIDがNablarchのユーザーIDとして設定されるように、規定のキーを使用してセッションストアに保持するようにします。

セッションストアの設定

セッションストアを使うために、いくつかの設定が必要になります。

SessionManagerの設定

まず、SessionManagerのコンポーネント定義を設定するために、rest-component-configuration.xmlファイルに次の定義を追加します。

<import file="nablarch/webui/session-store.xml" />

<component name="sessionManager" class="nablarch.common.web.session.SessionManager">
  <property name="defaultStoreName" value="${nablarch.sessionManager.defaultStoreName}" />
  <property name="availableStores">
    <list>
      <component-ref name="httpSessionStore" />
    </list>
  </property>
</component>

nablarch/webui/session-store.xmlファイルは、Nablarchが提供しているデフォルト設定の一つであり、セッションストアに関連するコンポーネント郡が定義されているため、これを読み込みます。(参考:デフォルト設定一覧 | Nablarch

セッションストアの保存先は複数あり、デフォルト設定では全てのセッションストアを使用できる設定になっています。(参考:セッションストアの特徴と選択基準 | Nablarch

ここではHTTPセッションストアのみ利用するため、sessionManager名のコンポーネント定義を上書きし、HTTPセッションストアのみ使用できる設定に変更しておきます。

セッション変数保存ハンドラの設定

次に、セッション変数保存ハンドラを設定します。

webFrontController名で定義しているコンポーネントのhandlerQueueプロパティに、REST APIの呼び出しで実行するハンドラを設定するため、ここにセッション変数保存ハンドラを追加します。セッション変数保存ハンドラは、先ほど読み込んだNablarchのデフォルト設定の中でsessionStoreHandler名のコンポーネントとして定義されているため、threadContextHandlerの手前に追加します。

<component name="webFrontController" class="nablarch.fw.web.servlet.WebFrontController">
  <property name="handlerQueue">
    <list>
...
      <component-ref name="sessionStoreHandler" />

      <component-ref name="threadContextHandler"/>
...

これで、セッションストアの設定は完了です。

ユーザー認証処理の実装

ログインでは名前とパスワードを使用した認証を行うため、ユーザー認証処理を実装します。

名前でアカウント検索するSQLの作成

ユーザー認証では名前とパスワードを使用しますが、それぞれテーブルが分かれています。ユニバーサルDAOでは、テーブルを結合して検索する場合はSQLを作成する必要があります。(参考:テーブルをJOINした検索結果を取得する | Nablarch

先ほど作成したAccountEntityクラスに対応するAccountEntity.sqlファイルを作成し、SQLを定義します。

src/main/resources/com/example/authentication/application/entity/AccountEntity.sql

FIND_BY_USERNAME =
select
    account.user_id,
    account.password
FROM
    account
INNER JOIN user_profile
    ON account.user_id = user_profile.user_id
WHERE
    user_profile.name = :userName

ユーザー認証クラスの作成

ユーザー認証処理を実装するため、まずは認証結果を表すAuthenticationResultクラスを作成します。

package com.example.authentication.application;

public class AuthenticationResult {

    private final Status status;

    private final String userId;

    private AuthenticationResult(Status status, String userId) {
        this.status = status;
        this.userId = userId;
    }

    public static AuthenticationResult success(String userId) {
        return new AuthenticationResult(Status.SUCCESS, userId);
    }

    public static AuthenticationResult passwordMismatch() {
        return new AuthenticationResult(Status.PASSWORD_MISMATCH, null);
    }

    public boolean isFailed() {
        return status != Status.SUCCESS;
    }

    public String userId() {
        if (isFailed()) {
            throw new UnsupportedOperationException();
        }
        return userId;
    }

    private enum Status {
        SUCCESS, PASSWORD_MISMATCH
    }
}

続いて、AuthenticationServiceクラスを作成します。

package com.example.authentication.application;

import com.example.authentication.application.entity.AccountEntity;
import nablarch.common.dao.NoDataException;
import nablarch.common.dao.UniversalDao;
import nablarch.core.repository.di.config.externalize.annotation.SystemRepositoryComponent;

import java.util.Map;

@SystemRepositoryComponent
public class AuthenticationService {

    public AuthenticationResult authenticate(String userName, String password) {
        AccountEntity accountEntity = findAccount(userName);
        if (!password.equals(accountEntity.getPassword())) {
            return AuthenticationResult.passwordMismatch();
        }
        return AuthenticationResult.success(accountEntity.getUserId());
    }

    private AccountEntity findAccount(String userName) {
        Map<String, String> condition = Map.of("userName", userName);
        return UniversalDao.findBySqlFile(AccountEntity.class, "FIND_BY_USERNAME", condition);
    }
}

ユニバーサルDAOを使用してDBからユーザーIDとパスワードを取得し、パスワードが一致すればユーザーIDも合わせて返すように実装します。

REST APIの実装

先ほど作成したAuthenticationActionクラスに、ログイン用のREST APIを実装します。

...
    private final AccountRegistrationService registrationService;

    private final AuthenticationService authenticationService;

    public AuthenticationAction(AccountRegistrationService registrationService,
                                AuthenticationService authenticationService) {
        this.registrationService = registrationService;
        this.authenticationService = authenticationService;
    }
...
    @Path("/login")
    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    public void login(ExecutionContext executionContext, LoginRequest requestBody) {
        ValidatorUtil.validate(requestBody);

        AuthenticationResult result = authenticationService.authenticate(requestBody.userName, requestBody.password);
        if (result.isFailed()) {
            throw new HttpErrorResponse(HttpResponse.Status.UNAUTHORIZED.getStatusCode());
        }
        SessionUtil.invalidate(executionContext);
        SessionUtil.put(executionContext, "user.id", result.userId());
    }

    public static class LoginRequest {
        @NotNull
        public String userName;

        @NotNull
        public String password;
    }

ユーザー名とパスワードが送信されているかチェックし、先ほど実装したユーザー認証機能を呼び出します。

認証に成功したら、まずSessionUtil#invalidateを呼び出し、セッションを一度破棄して新しいセッションを使用するようにします。こうすることで、セッション管理の不備を狙ったセッション・ハイジャックの対策になります。(参考:セッション管理の不備 | 安全なウェブサイトの作り方

SessionUtil#invalidateの引数に必要なExecutionContextについては、アクションクラスのメソッドの引数に宣言することで、Nablarchから受け取ることができます。(参考:リソース(アクション)クラスの実装に関して | Nablarch

認証に成功するとユーザーIDが返されるので、スレッドコンテキストのユーザーID属性に設定するため、セッションストアにuser.id名で保存します。(参考:ユーザーIDを設定する | Nablarch

パスワードが間違っていた場合はnullが返されるので、nullであれば認証失敗としてステータスコードが401 Unauthorizedのエラーレスポンスを返すように実装します。

REST APIのテスト

テストデータの追加

テストで使用するテストデータを、V9999__testdata.sqlに追加します。

src/test/resources/db/testdata/V9999__testdata.sql

INSERT INTO account (user_id, password) VALUES ('1010', 'pass');
INSERT INTO user_profile (user_id, name) VALUES ('1010', 'login-test');

認証に成功するテストの作成

AuthenticationRestApiTestに、認証が成功する場合のテストを追加します。

    @Test
    public void RESTAPIでログインできる() throws Exception {
        RestMockHttpRequest request = post("/api/login")
                .setHeader("Content-Type", MediaType.APPLICATION_JSON)
                .setBody(Map.of(
                        "userName", "login-test",
                        "password", "pass"));
        HttpResponse response = sendRequest(request);

        assertStatusCode("ログイン", HttpResponse.Status.NO_CONTENT, response);

        openApiValidator.validate("login", request, response);
    }

続いて、テストを実行する前準備として、PostgreSQLを起動しておきます。PostgreSQLのコンテナを起動していない場合は、backendディレクトリで次のコマンドを実行します。

$ docker-compose -f docker/docker-compose.dev.yml up -d

続いて、次のコマンドでコンテナが起動していることを確認します。

$ docker-compose -f docker/docker-compose.dev.yml ps
      Name                     Command              State           Ports
----------------------------------------------------------------------------------
docker_postgres_1   docker-entrypoint.sh postgres   Up      0.0.0.0:5432->5432/tcp

PostgreSQLが起動していることを確認できたら、Mavenでテストを実行します。

mvn test

特にエラーが発生せず、テストが成功することを確認します。

パスワードの不一致で認証に失敗するテストの作成

続けて、パスワードの不一致で認証に失敗する場合のテストを追加します。

    @Test
    public void パスワードが不一致の場合_ログインに失敗して401になる() throws Exception {
        RestMockHttpRequest request = post("/api/login")
                .setHeader("Content-Type", MediaType.APPLICATION_JSON)
                .setBody(Map.of(
                        "userName", "login-test",
                        "password", "fail"));
        HttpResponse response = sendRequest(request);

        assertStatusCode("ログイン", HttpResponse.Status.UNAUTHORIZED, response);

        openApiValidator.validate("login", request, response);
    }

テストを実行し、成功することを確認します。

名前の不一致で認証に失敗するテストの作成

続けて、名前の不一致で認証に失敗する場合のテストを追加します。

    @Test
    public void 名前が不一致の場合_ログインに失敗して401になる() throws Exception {
        RestMockHttpRequest request = post("/api/login")
                .setHeader("Content-Type", MediaType.APPLICATION_JSON)
                .setBody(Map.of(
                        "userName", "fail-test",
                        "password", "pass"));
        HttpResponse response = sendRequest(request);

        assertStatusCode("ログイン", HttpResponse.Status.UNAUTHORIZED, response);

        openApiValidator.validate("login", request, response);
    }

テストを実行すると、次のようにステータスコードが401 Unauthorizedではなく500 Internal Server Errorになり失敗します。

[ERROR] Failures:
[ERROR]   AuthenticationRestApiTest.名前の不一致でログインに失敗すると401になる:70->SimpleRestTestSupport.assertStatusCode:283->SimpleRestTestSupport.assertStatusCode:294 ログイン [HTTP STATUS] expected:<401> but was:<500>

他の出力結果を確認すると、次のようなスタックトレースが出力されており、先ほど実装したAuthenticationService#findAccountの中で呼び出しているUniversalDao#findBySqlFileで、NoDataExceptionの例外が送出されていることが分かります。

nablarch.common.dao.NoDataException
    at nablarch.common.dao.BasicDaoContext.findBySqlFile(BasicDaoContext.java:267)
    at nablarch.common.dao.UniversalDao.findBySqlFile(UniversalDao.java:165)
    at com.example.authentication.application.AuthenticationService.findAccount(AuthenticationService.java:48)

UniversalDao#findBySqlFileJavadocを確認すると、検索条件に該当するレコードが存在しない場合はNoDataExceptionの例外を送出すると定義されています。

名前で検索して見つからなかった場合はこれに該当するため、例外をハンドリングするように実装します。

まずは、AuthenticationResultクラスに、見つからなかった場合の結果を追加します。

    public static AuthenticationResult nameNotFound() {
        return new AuthenticationResult(Status.NAME_NOT_FOUND, null);
    }
...
    private enum Status {
        SUCCESS, NAME_NOT_FOUND, PASSWORD_MISMATCH
    }

続いて、例外をハンドリングしてnullを返すようにAuthenticationServiceクラスのfindAccountメソッドを修正します。

private AccountEntity findAccount(String userName) {
...
    try {
        return UniversalDao.findBySqlFile(AccountEntity.class, "FIND_BY_USERNAME", condition);
    } catch (NoDataException e) {
        return null;
    }
...

同じクラスのauthenticateメソッドに、findAccountメソッドでnullが返された場合のために判定を追加します。

public AuthenticationResult authenticate(String userName, String password) {
    AccountEntity accountEntity = findAccount(userName);
    if (accountEntity == null) {
        return AuthenticationResult.nameNotFound();
    }
...

もう一度テストを実行し、成功することを確認します。

これで、ログインのREST APIの実装は完了です。

results matching ""

    No results matching ""