Jakarta EE Security API の始め方

blog1.mammb.com


はじめに

Java EE 8 で導入された Security API(JSR-375) ですが、取りまとまった情報があまり無いため、ここにまとめます。

Jakarta EE とのバージョン対比は以下のようになります。

Version EE Version
Jakarta Security 1.0 Jakarta EE 8
Jakarta Security 2.0 Jakarta EE 9
Jakarta Security 3.0 Jakarta EE 10

Jakarta EE 10 では、OpenID Connect や OAuth2 の認証メカニズムが追加されたり、URL 別で認証方法を定義できるようになるなど多くの機能追加が予定されています。

RI は Soteria になります。


Security API とは

アプリケーションの認証方法は、旧来アプリケーションサーバで独自に実現されていましが、Java EE 8 時代に JSR-375 として共通 API が導入されました。

Security API では現在以下の機能が提供されています。

  • 認証 API(AuthenticationMechanism)
  • IdentityStore API
  • SecurityContext API

AuthenticationMechanism は、ベーシック認証であったり、フォーム認証であったり、といった認証の手段を扱います。

IdentityStore では、データベースであったり、LDAPであったり、といった認証対象のIDストアを扱います。 クレデンシャルを検証してグループ・メンバーシップを取得するために使用されます。

SecurityContext はセキュリティへのアクセスポイントを提供します。 以前はばらばらだったセキュリティ機能へのアクセス手段がこの SecurityContext を経由して統一できます。 CDI や EL式を経由して SecurityContext に統一的にアクセスすることができます。


以下のようなAPIについては、現在は提供されていません。 今後のバージョンアップで追加されていくものとなります。

  • パスワードエイリアス API
  • ロール/パーミッション割当 API
  • 認可インタセプター API


AuthenticationMechanism

AuthenticationMechanism は、認証の手段を扱います。これにはビルトインのサポートがあり、以下のアノテーションで簡単に機能実現できます。

  • BasicAuthenticationMechanismDefinition
  • FormAuthenticationMechanismDefinition
  • CustomFormAuthenticationMechanismDefinition

BasicAuthenticationMechanismDefinition はその名の通り、BASIC認証を扱います。

FormAuthenticationMechanismDefinition は、Servlet で旧来からある、j_usernamej_password を使った以下のようなフォームを扱うものです。

<form method="POST" action="j_security_check">
  <input type="text" name="j_username">
  <input type="password" name="j_password" autocomplete="off">
</form>

たいていの場合は CustomFormAuthenticationMechanismDefinition を使うことになると思うので、CustomFormAuthenticationMechanismDefinition を JSF と共に使うケースに絞って話を進めます。

ここでは深入りしませんが、上記の認証方式は、以下のインターフェースの実装を提供し、独自に認証を定義することもできるようになっています。

package jakarta.security.enterprise.authentication.mechanism.http;

public interface HttpAuthenticationMechanism {

    AuthenticationStatus validateRequest(
        HttpServletRequest request, HttpServletResponse response, HttpMessageContext httpMessageContext)
        throws AuthenticationException;
   
    default AuthenticationStatus secureResponse(HttpServletRequest request, HttpServletResponse response,
        HttpMessageContext httpMessageContext) throws AuthenticationException {
        return SUCCESS;
    }
    
    default void cleanSubject(HttpServletRequest request, HttpServletResponse response,
        HttpMessageContext httpMessageContext) {
        httpMessageContext.cleanClientSubject();
    }
}


CustomFormAuthenticationMechanismDefinition

CDI ビーンとして以下のように定義することで、カスタムフォーム認証が有効になります。

@CustomFormAuthenticationMechanismDefinition(
    loginToContinue = @LoginToContinue(
        loginPage="/login.xhtml",
        errorPage="/login.xhtml",
        useForwardToLogin = false
    )
)
@ApplicationScoped
public class AuthenticationMechanismConfig {
}

@LoginToContinue アノテーションにて、ログインページやエラーページのURLを指定します。

useForwardToLogin では、ログインページへのページ遷移にリダイレクトかフォワードの何れを使うかを指定します。詳しくは JavaDoc を参照ください。

@RememberMe を合わせて利用することで、Cookie を使って、一定期間の再認証をスキップさせることもできます。


JSF のバッキングビーンは以下のように SecurityContext.authenticate() を介して認証を行います。

@Model
public class LoginModel {

    @NotNull
    private String username;

    @NotNull
    private String password;

    @Inject
    private SecurityContext securityContext;
    @Inject
    private FacesContext facesContext;
    @Inject
    private ExternalContext externalContext;


    public void login() throws Exception {

        Credential credential =
            new UsernamePasswordCredential(username, new Password(password));

        externalContext.invalidateSession();

        AuthenticationStatus status = securityContext.authenticate(
            (HttpServletRequest) externalContext.getRequest(),
            (HttpServletResponse) externalContext.getResponse(),
            AuthenticationParameters.withParams()
                .newAuthentication(true)
                .credential(credential));

        switch (status) {
            case SEND_CONTINUE, NOT_DONE -> facesContext.responseComplete();
            case SEND_FAILURE -> facesContext.addMessage(null,
                new FacesMessage(FacesMessage.SEVERITY_ERROR, "Authentication failed", null));
            case SUCCESS -> {
                externalContext.redirect(externalContext.getRequestContextPath() + "/private/home.xhtml");
            }
        }
    }
    // getter/setter
}

認証成功時には AuthenticationStatus.SUCCESS が得られます。

なお、現在は、web.xml または web-fragment.xml にて security-constraint を定義する必要があります。

<?xml version="1.0" encoding="UTF-8"?>
<web-app
    xmlns="https://jakarta.ee/xml/ns/jakartaee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_5_0.xsd"
    version="5.0">

    <security-constraint>
        <web-resource-collection>
            <web-resource-name>auth</web-resource-name>
            <url-pattern>/private/*</url-pattern>
        </web-resource-collection>
        <auth-constraint>
            <role-name>admin</role-name>
            <role-name>user</role-name>
        </auth-constraint>
    </security-constraint>

</web-app>

web-fragment.xml の場合も定義は同様です。

<?xml version="1.0" encoding="UTF-8"?>
<web-fragment
    xmlns="https://jakarta.ee/xml/ns/jakartaee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-fragment_5_0.xsd"
    version="5.0">

    <security-constraint>
        <web-resource-collection>
            <web-resource-name>auth</web-resource-name>
            <url-pattern>/private/*</url-pattern>
        </web-resource-collection>
        <auth-constraint>
            <role-name>admin</role-name>
            <role-name>user</role-name>
        </auth-constraint>
    </security-constraint>

</web-fragment>

/private/*以下のURLへのアクセスには認証が必要になり、@CustomFormAuthenticationMechanismDefinition に従って認証処理が行われます。

login.xml は以下のような感じになります。

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="http://xmlns.jcp.org/jsf/html"
      xmlns:jsf="http://xmlns.jcp.org/jsf">

<head jsf:id="idHead">
    <meta charset="utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1"/>
    <title>Login</title>
</head>
<body jsf:id="idBody">
    <h2>Login to continue</h2>
    <h:messages />
    <form jsf:id="form">
        <p>
            <strong>Username </strong>
            <input jsf:id="username" type="text"
                jsf:value="#{loginModel.username}" />
        </p>
        <p>
            <strong>Password </strong>
            <input jsf:id="password" type="password"
                jsf:value="#{loginModel.password}" />
        </p>
        <p>
            <input type="submit" value="Login"
                jsf:action="#{loginModel.login}" />
        </p>
    </form>
</body>
</html>

認証の方法は定義できたので、次に IdentityStore を見ていきます。


IdentityStore API

IdentityStore では、どのように クレデンシャルを検証しするかを定義します。 現在は以下のものがビルトインでサポートされています。

  • DatabaseIdentityStoreDefinition
  • LdapIdentityStoreDefinition

ここでは、一般的に用いられる DatabaseIdentityStoreDefinition について見ていきます。

独自に実装する場合は、以下のインターフェースを、アプリケーションスコープの CDI ビーンとして実装することでカスタマイズできます。

package jakarta.security.enterprise.identitystore;

public interface IdentityStore {

    Set<ValidationType> DEFAULT_VALIDATION_TYPES = EnumSet.of(VALIDATE, PROVIDE_GROUPS);

    default CredentialValidationResult validate(Credential credential) {
        try {
            return CredentialValidationResult.class.cast(
                MethodHandles.lookup()
                             .bind(this, "validate", methodType(CredentialValidationResult.class, credential.getClass()))
                             .invoke(credential));
        } catch (NoSuchMethodException e) {
            return NOT_VALIDATED_RESULT;
        } catch (Throwable e) {
            throw new IllegalStateException(e);
        }
    }

    default Set<String> getCallerGroups(CredentialValidationResult validationResult) {
        return emptySet();
    }

    default int priority() {
        return 100;
    }

    default Set<ValidationType> validationTypes() {
        return DEFAULT_VALIDATION_TYPES;
    }

    enum ValidationType {
        VALIDATE,
        PROVIDE_GROUPS
    }
}


DatabaseIdentityStoreDefinition

以下のような CDI ビーン にアノテーションとして @DatabaseIdentityStoreDefinition を付与することで、データベースでの認証が有効になります。

@DatabaseIdentityStoreDefinition(
    dataSourceLookup = java:app/App/DefaultDataSource,
    callerQuery = IdentityStoreConfig.CALLER_QUERY,
    groupsQuery = IdentityStoreConfig.GROUPS_QUERY,
    hashAlgorithmParameters = "${identityStoreConfig.hashAlgorithmParameters}"
)
@Named
@ApplicationScoped
public class IdentityStoreConfig {

    public static final String CALLER_QUERY = """
        select users.password
        from users
        where users.name = ?""";

    public static final String GROUPS_QUERY = """
        select groups.name
        from users
            join users_groups on users.id = users_groups.users_id
            join groups on users_groups.groups_id = groups.id
        where users.name = ?""";

    public static Map<String, String> HASH_PARAMS = Map.of(
        "Pbkdf2PasswordHash.Iterations","3072",
        "Pbkdf2PasswordHash.Algorithm", "PBKDF2WithHmacSHA512",
        "Pbkdf2PasswordHash.SaltSizeBytes", "64");

    public String[] getHashAlgorithmParameters() {
        return HASH_PARAMS.entrySet().stream()
            .map(e -> e.getKey() + "=" + e.getValue()).toArray(String[]::new);
    }

}

callerQuery でユーザ(Security API では Caller と呼ぶ)のパスワード取得SQL、groupsQuery では Caller のグループを取得するSQLを定義します。

アノテーションの中ではEL式が使えるので、ハッシュアルゴリズムは、@Named の CDIビーンから供給できます。


認証テーブルの作成

DatabaseIdentityStoreDefinition で使う SQL に対応した エンティティ は以下のように定義できます。

@Entity(name = "USERS")
public class User extends BaseEntity<User> {

    @NotNull
    @Size(min = 2, max = 25)
    private String name;

    @NotNull
    private String password;

    @ManyToMany
    private Set<Group> groups;

    // ...
}
@Entity(name = "GROUPS")
public class Group extends BaseEntity<Group> {

    @NotNull
    private String name;

    // ...
 }

初期データ登録は以下のように行なえます。

@Startup
@Singleton
public class UserInitializeService {

    @Inject
    private EntityManager em;

    @Inject
    private Pbkdf2PasswordHash passwordHash;


    @PostConstruct
    public void init() {
        TypedQuery<Long> query = em.createQuery("SELECT COUNT(e) FROM USERS e", Long.class);
        if (query.getSingleResult() == 0) {

            var adminGroup = Group.of("admin");
            var userGroup = Group.of("user");
            em.persist(adminGroup);
            em.persist(userGroup);

            passwordHash.initialize(IdentityStoreConfig.HASH_PARAMS);
            var admin = User.of("admin", passwordHash.generate("admin".toCharArray()), adminGroup, userGroup);
            var user = User.of("user", passwordHash.generate("user".toCharArray()), userGroup);
            em.persist(admin);
            em.persist(user);
        }
    }
}

Pbkdf2PasswordHash にて、パスワードのハッシュが生成できます。


まとめ

Jakarta EE Security API の簡単な利用方法について説明しました。

具体的な実装例は以下を参照してください。

github.com

以下のサンプルが確認できます。


ログイン画面


ログイン後