2019年9月16日 (月)

Apache Wicketを使ってみる[4] -認証/認可編-

0. Wicketで認証/認可を扱うには

wicket-auth-roles というdependencyが必要。

<dependency>
  <groupId>org.apache.wicket</groupId>
  <artifactId>wicket-auth-roles</artifactId>
  <version>8.5.0</version>
</dependency>

1. Wicketでの認証

通常の WebApplication に認証情報を処理できるような拡張が施された AuthenticatedWebApplication と、認証処理と認証情報のセッションストアへの格納を行う AuthenticatedWebSession で構成される。

二つとも abstract なので、使うには継承したうえで実装しなければならない処理がある。

AuthenticatedWebApplication AuthenticatedWebSession
  • protected Class<? extends WebPage> getSignInPageClass()
    サインインページを構成するクラスを返す
  • protected boolean authenticate(String username, String password)
    ユーザー名とパスワードから認証を行う
  • protected Class<? extends AbstractAuthenticatedWebSession> getWebSessionClass()
    アプリケーションで使うセッションストアのクラスを返す

上記のユーザーガイドからの抜粋をKotlinで書いてみると、こんな感じになる。

  • セッションストアクラス
class BasicAuthenticationSession(request: Request) : AuthenticatedWebSession(request) {
  override fun authenticate(username: String, password: String) : Boolean {
    // ここで認証処理を行う
    // 通常であれば、DBアクセスしてユーザーの有無やパスワード比較とかをする、はず
    return username == password && username == "wicketer"
  }

  override fun getRoles() : Roles {
    // ロールベースのアクセス制御用に、ログイン中ユーザーのロールを返す
    // 通常であれば、セッションストアのユーザー情報からロールを引いたりする、はず
    return Roles()
  }
}
  • アプリケーションクラス
class WicketApplication() : AuthenticatedWebApplication() {
  override fun getHomePage() : Class<out WebPage> {
    // いわゆる「スタートページ」を構成するクラスを返す
    return HomePage::class.java
  }
  override fun getWebSessionClass() : Class<out AbstractAuthenticatedWebSession> {
    // アプリケーションでつかうセッションストアを返す
    return BasicAuthenticationSession::class.java
  }
  override fun getSignInPageClass() : Class<out WebPage> {
    // いわゆる「ログインページ」を構成するクラスを返す
    return SignInPage::class.java
  }
}
  • サインインページ
class SignInPage() : WebPage() {
  private var username: String = ""
  private var password: String = ""

  override fun onInitialize() {
    super.onInitialize()

    // ログインフォームを構成するStatelessForm
    val form: StatelessForm<Unit> = object : StatelessForm<Unit>("form") {
      override fun onSubmit() {
        if (username.length == 0) {
          return
        }

        // 認証処理を呼び出し、認証成功のときは前ページで設定されたリダイレクト先へ遷移する
        val authResult = AuthenticatedWebSession.get().signIn(username, password)
        if (authResult) {
          // ここではそのままリダイレクトしているが、ここでユーザーロールのチェックとかをはさむ、はず
          continueToOriginalDestination()
        }
      }
    }

    // ModelとTextFieldをformに追加する
    form.model = CompoundPropertyModel(this)
    form.add(TextField<String>("username"))
    form.add(PasswordTextField("password"))

    add.(form)
  }
}

サインインページのデフォルト実装はあるけれど、同じくデフォルト実装のパネルと組み合わせて使う想定のようで、自力で書くのと大差ないかも....

2. 途中で認証をはさみたいときは

「トップページとかは普通に見れるようにするが、あるページは会員だけに見せたい」といった場合、途中でユーザー認証を行い、認証に成功したらユーザーがもといたページに戻す、といった処理をやることになる。

そういった場合は、以下の二つの方法をとる、とのこと。[出典]

いずれも、実行時に遷移前の状態をセッションストアに保存したあと、認証に成功した場合認証前の状態に復元してくれるが、認証ページへの遷移直前に RestartResponseAtInterceptPageException が内部的にスローされてページ遷移するので、以後の処理は行われなくなる。

3. Wicketでの認可

いわゆる処理に対する権限チェックである。先ほどの例で出てきた AuthenticatedWebSession#getRoles() も、認可に関する処理の一つ。
「ログインしているか否か」も、認可の範囲に入る模様。

基本処理は IAuthorizationStrategy で規定されている

boolean isActionAuthorized(Component, Action) アクションが許可されているかどうかを返す。
boolean isInstantiationAuthorized(Class<T extends IRequestableComponent>) 指定されたコンポーネントをインスタンス化できるかどうかを返す。
boolean isResourceAuthorized(IResource, PageParameters) リクエストのパラメータが、リソースへのアクセスが許可されているかどうかを返す。

だが、すべてを許可するALLOW_ALLという実装のほか、ページ単位のチェックを行う規定処理がすでに組み込まれている。個人的には、一番しっくりきたのがロールベース戦略として紹介されている MetaDataRoleAuthorizationStrategyAnnotationsRoleAuthorizationStrategy だった。
それぞれ、用途的には以下のような違いがある。

MetaDataRoleAuthorizationStrategy アプリケーション起動時に、あらかじめ使用可能な権限をページごとに列挙しておく
AnnotationsRoleAuthorizationStrategy ページクラスに対し、アノテーションで権限を宣言していく

両者とも一長一短ではあるけど、たとえば、 AdminOperatablePage というページに「サインインしていたら表示可能、スーパーユーザーなら中のFormを操作可能」という権限を設定する場合、

  • MetaDataRoleAuthorizationStrategy のばあい
class WicketApplication() : AuthenticatedWebApplication() {
  override fun init() {
    securitySettings.authorizationStrategy = MetaDataRoleAuthorizationStrategy(this)

    // ログインしていたら "AdminOperatablePage" はインスタンス化を許可
    MetaDataRoleAuthorizationStrategy.authorize(AdminOperatablePage::class.java, "SIGNED_IN")

    // adminForm というコンポーネントは、"ADMIN"なら操作可能にする
    // "ADMIN"でないときはロックされる
    MetaDataRoleAuthorizationStrategy.authorize(adminForm, Action.ENABLE, "ADMIN")
  }
}
  • AnnotationsRoleAuthorizationStrategy のばあい
@AuthorizeInstantiation("SIGNED_IN")
@AuthorizeAction(action = "ENABLE", roles = {"ADMIN"})
class AdminOperatablePage(params: PageParameters) : WebPage(params) {
  // @AuthorizeInstantiation で "SIGNED_IN" を持つ場合にインスタンス化を許可
  // ここの @AuthorizeAction は、ページ全体に効果がある
  ...
}

....といった感じになるはず。

なお、権限は AuthenticatedWebSession#getRoles() で取得されるので、先ほどのセッションストアクラスを書き直してみると、

class BasicAuthenticationSession(request: Request) : AuthenticatedWebSession(request) {

  override fun getRoles() : Roles {
    // ロールは中身がHashSet<String>なので、条件に応じたロール識別文字列をaddしていく
    // @see https://ci.apache.org/projects/wicket/apidocs/8.x/org/apache/wicket/authroles/authorization/strategies/role/Roles.html
    val resultRoles = Roles()

    if (isSignedIn()) {
      // たとえば、サインインしていたら"SIGNED_IN"をおく、とか
      resultRoles.add("SIGNED_IN")
    }
    if (username == "superuser") {
      // スーパーユーザーだったら"ADMIN"を置く、とか
      resultRoles.add("ADMIN")
    }
    // このほか、ユーザーのオブジェクトを保管しているなら、そのオブジェクトにぶら下がっている
    // ロールをaddしていくことになる、はず

    return resultRoles
  }
}

...といった具合になるはず。

4. 認可に失敗したときは

認可に失敗したとき、デフォルトでは

  • ユーザーがサインインしていないときは、既定のサインインページへ遷移させる
  • サインインしているがコンポーネントが認可できない場合は、 UnauthorizedInstantiationException がスローされる

....という挙動だが、カスタム処理にしたい場合は IUnauthorizedComponentInstantiationListener というイベントリスナがあるので、これの中で処理する。

  securitySettings.unauthorizedComponentInstantiationListener = IUnauthorizedComponentInstantiationListener {
    // 認可失敗を処理する "AuthWarningPage" というページをレスポンスにする
    it.setResponsePage(AuthWarningPage::class.java)
  }

ロールの検証を追加するなら、こんな感じになるはず。

  securitySettings.unauthorizedComponentInstantiationListener = object : IRoleCheckingStrategy, IUnauthorizedComponentInstantiationListener {
    override fun onUnauthorizedInstantiation(component: Component?) {
      // 認可失敗を処理する "AuthWarningPage" というページをレスポンスにする
      component!!.setResponsePage(AuthWarningPage::class.java)
    }

    override fun hasAnyRole(roles: Roles): Boolean {
      // 指定ロールが見つかったらtrueを返す処理を書く
      var result = roles.hasRole("ADMIN")
      return result
    }
  }

まぁ、このあたりはデフォルト実装でもどうにかなるレベルなので、やるかどうかはケースバイケースか。

| | コメント (0)

2019年9月14日 (土)

MavenのJettyにDataSourceを設定するのに苦労した話

0. やっぱりデータソースは外に出したいのです

前回のデータアクセスの話では、

val database: Sql2o = Sql2o("jdbc:mysql://localhost:3306/mydb", "mydbadmin", "mydbadmin")

と、ソースコードに直接書いていたけれど、実際の運用を考えるとDBの接続先設定とプロダクションコードは極力分離したいところ。

幸いにも、Mavenで作ったプロジェクトのアーキタイプにはJettyがdependencyに組み込まれていて、test側に開発サーバとしてJettyを起動するときの処理が最初から入っている。
ここに DataSource 設定を書いてやれば、理屈の上ではプロダクションコードからDBの接続先設定を分離できることになる。

で、前置きが長くなってしまったけど、Mavenで依存に入っているJettyにDataSourceを設定するのに結構苦労をしたので、その時の備忘録的なメモを書いてみる。

1. やること(というかやったこと)

やったことをざっくりまとめると、以下のとおり。

  1. pom.xmlに依存とプラグイン設定を追加
  2. Jettyの設定ファイル追加
  3. web.xmlにDataSourceルックアップ設定を追加
  4. DataSourceを使うようデータアクセス部分を変更

1. pom.xmlに依存とプラグイン設定を追加

なにはなくともdependencyに必要なライブラリを入れなければならない。
今回はコネクションプールライブラリを挟む方針で、高性能を謳っていることで話題のHikariCPを使ってみることにする。

<dependencies>
  <dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.17</version>
    <!-- Jettyがtestスコープで動くのでそちらに合わせる -->
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
    <version>3.3.1</version>
    <!-- Jettyがtestスコープで動くのでそちらに合わせる -->
    <scope>test</scope>
  </dependency>
</dependencies<

Jettyがtestスコープで動くので、コネクションプールとJDBCドライバもそれに合わせておく。

次に、JettyがHikariCPとconnector-jを扱う設定を読み込めるようにするのだけど、これはMavenプラグインの設定をいじる必要がある。
追加するとこんな感じになるはず。

<plugins>
  <plugin>
    <groupId>org.eclipse.jetty</groupId>
    <artifactId>jetty-maven-plugin</artifactId>
    <version>${jetty9.version}</version>
    <configuration>
      <-- 追加、testスコープのclasspathを優先する設定 -->
      <useTestScope>true</useTestScope>
      <-- 追加ここまで -->
      <systemProperties>
        <systemProperty>
          <name>maven.project.build.directory.test-classes</name>
          <value>${project.build.directory}/test-classes</value>
        </systemProperty>
      </systemProperties>
      <jettyXml>
        ${project.basedir}/src/test/jetty/jetty.xml,${project.basedir}/src/test/jetty/jetty-ssl.xml,${project.basedir}/src/test/jetty/jetty-http.xml,${project.basedir}/src/test/jetty/jetty-https.xml
      </jettyXml>
      <!-- 以下追加 -->
      <webApp>
        <!-- web.xml を読み込む先を指定する設定 -->
        <descriptor>${project.basedir}/src/main/webapp/WEB-INF/web.xml</descriptor>
        <!-- 「データソースとして定義する内容」を書いたファイルを読み込む設定 -->
        <jettyEnvXml>${project.basedir}/src/test/jetty/jetty-env.xml</jettyEnvXml>
      </webApp>
      <!-- 追加ここまで -->
    </configuration>
  </plugin>
</plugins>

2. Jettyの設定ファイル編集

前述のpom.xml

に追加したjetty-env.xmlを、src/test/jettyに作る。

設定例をググっていると、 Configure の class に org.eclipse.jetty.webapp.WebAppContext を使う例が結構ひっかっかると思うけど、これをやると ClassCastException で設定に失敗するので org.eclipse.jetty.maven.plugin.JettyWebAppContext に変更している。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE Configure PUBLIC "-//Jetty//Configure//EN" "http://www.eclipse.org/jetty/configure_9_0.dtd">
<!-- Configure の class は、 Maven プラグインから動くのでこっちを使う -->
<Configure id="wac" class="org.eclipse.jetty.maven.plugin.JettyWebAppContext">
  <New id="myDS" class="org.eclipse.jetty.plus.jndi.Resource">
    <Arg><Ref refid="wac" /></Arg>
    <!-- ここが DataSource の名前になる -->
    <Arg>jdbc/datasource</Arg>
    <Arg>
      <New class="com.zaxxer.hikari.HikariDataSource">
        <Arg>
          <New class="com.zaxxer.hikari.HikariConfig">
            <!-- 以下 setter 部分の意味は JavaDoc とかを参照のこと -->
            <Set name="maximumPoolSize">20</Set>
            <Set name="driverClassName">com.mysql.cj.jdbc.Driver</Set>
            <Set name="jdbcUrl">jdbc:mysql://localhost:3306/mydb</Set>
            <Set name="username">mydbadmin</Set>
            <Set name="password">mydbadmin</Set>
          </New>
        </Arg>
      </New>
    </Arg>
  </New>
</Configure>

3. web.xmlにDataSourceルックアップ設定を追加

下回りは設定できたので、プロダクションコードに関連する箇所をいじって、 DataSource をルックアップできるようにする。

pom.xmlに追加したとおり、/src/main/webapp/WEB-INF/web.xmlに以下を追加。

<web-app xmlns="http://java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://www.oracle.com/webfolder/technetwork/jsc/xml/ns/javaee/web-app_3_1.xsd"
    version="3.1">

  <!-- "jdbc/datasource" をデータソースとして名付ける設定 -->
  <resource-ref>
    <res-ref-name>jdbc/datasource</res-ref-name>
    <res-type>javax.sql.DataSource</res-type>
    <res-auth>Container</res-auth>
  </resource-ref>

</web-app>

4. DataSourceを使うようデータアクセス部分を変更

ここまでくれば DataSource をルックアップできるようになるので、接続設定を DataSource を使うように変更する。
例によって Kotlin で書いている。

open class DatabaseHelper {
    // init ブロックで初期化されるので空でよい
    //val database: Sql2o = Sql2o("jdbc:mysql://localhost:3306/mydb", "mydbadmin", "mydbadmin")
    val database: Sql2o

    init {
        // InitialContext から "jdbc/datasource" をルックアップする
        val context = InitialContext()
        val datasource = context.lookup("java:comp/env/jdbc/datasource") as DataSource
        // ルックアップした DataSource オブジェクトで Sql2o を初期化
        database = Sql2o(datasource)
    }
}

この状態で mvn package を実行して war を作り、 Tomcat とかのアプリケーションコンテナにデプロイしてもちゃんと動く。ここまで来るのに長かった....。

| | コメント (0)

2019年9月 2日 (月)

続:Apache Wicketを使ってみる -データアクセス編-

0. データアクセスをどうするか

Quick Start Wizardで作成したMavenプロジェクトには、当然のことながらデータアクセスに関するライブラリがないので、自力でどうにかするしかない。

とはいえ、Spring Bootのような至れり尽くせりな環境でもないけど、ローレベルなJDBCをスクラッチで書き起こす元気もないので、今回はsql2oというライブラリを使ってみることにする。

1. sql2oとは

sql2o本家、およびGitHubの記述をざっくりまとめると、こんな感じ。

位置づけ的にはDapper.NETRoom Persisitence Libraryに近いかも。

  • 小さく軽量なデータアクセス用ライブラリ
  • JDBCのResultSetをPOJOに変換する機能を提供するが、SQLを生成する機能はない
  • 他の著名なライブラリに比べ、高速に(モノによっては6,7倍高速)動作する

導入はMavenで。執筆時点の最新版は1.6.0だった。
今回はDBMSにMySQLを使ってみることにするので、Connector/Jも一緒に設定しておく。

<dependency>
  <groupId>org.sql2o</groupId>
  <artifactId>sql2o</artifactId>
  <version>1.6.0</version>
</dependency>
<dependency>
  <groupId>mysql</groupId>
  <artifactId>mysql-connector-java</artifactId>
  <version>8.0.17</version>
</dependency>

2. 検索系処理

データベースへの接続は、Sql2oのコンストラクタに接続文字列を渡すことで作ってくれる。
なお、あえてすべてKotlinで書いてみた。

val database: Sql2o = Sql2o("jdbc:mysql://localhost:3306/mydb", "mydbadmin", "mydbadmin")

オープン処理とかは、DAOの基底クラスにプロパティを書いておくと扱いやすいかも。

/** トランザクションなしのConnection */
val connection: Connection
    get() {
        return database.open()
    }
/** トランザクション開始ありのConnection */
val connectionWithTran: Connection
    get() {
database.beginTransaction() }

で、検索系はSQLをそのまま書き、それを Connection#createQuery(String) に渡して Query オブジェクトを作り、 Query#executeAndFetch(Class) を実行すると ResultSet を POJO へバインドしてくれる。

// SQLをそのまま書く。長いSQLの場合はヒアドキュメントを使うと楽かも
val sql = "select todo_id, todo_title, created_at, finished from todo"
// 接続をオープン
val conn = connection
try {
    // use 関数でクエリ実行(Javaでいうところのtry-with-resources)
    conn.use {
        // setAutoDeriveColumnNames はスネークケース→キャメルケースを自動変換する設定
        val query = conn.createQuery(sql).setAutoDeriveColumnNames(true)
        val ret: MutableList<ToDo> = query.executeAndFetch(ToDo::class.java)
    }
} catch (e: Exception) {
    // do something...
}

3. 更新系処理

更新系処理も、検索系処理同様 createQuery(String) でプリペアードステートメントを作り、 Connection#executeUpdate() で実行する。

val sql = "insert into todo (todo_title, created_at, finished) values (:todoTitle, :createdAt, :finished)"
// トランザクション開始ありのConnectionを取得
val tran = connectionWithTran
try {
    tran.use {
        val stmt = tran.createQuery(sql)
                // プリペアードステートメントへバインドするパラメータを追加していく
                .addParameter("todoTitle", todo.todoTitle)
                .addParameter("createdAt", todo.createdAt)
                .addParameter("finished", todo.finished)
        stmt.executeUpdate()
        // use 関数内で commit()を発行
        tran.commit()
    }
} catch (e: Exception) {
    // do something...
    // ロールバックは暗黙的に行われる
}

なお、sql2oには宣言的トランザクションはないので、トランザクション制御は自分でやらなければならない。

4. 最後に

自力でSQLのチューニングをしたほうが手っ取り早い場合なんかには、こういうライブラリのほうがいいかもしれない。

| | コメント (0)