当前位置:Java -> Jakarta EE安全性:使用身份存储

Jakarta EE安全性:使用身份存储

作为现代业务应用和服务的最重要方面之一,Java 企业级应用程序的安全性并没有等到 Jakarta EE 10 的爆发。从早期的 Y2K 开始的 J2EE 的首次发布开始,安全性就是企业软件架构的关键所在。随着规范的逐渐发展,安全性逐渐加强,但我们今天所知的 JSR-375 是几年前随 Jakarta EE 8 出现的,当时以 Java EE Security API 1.0 的名义出现。Jakarta EE 10 的当前版本随 Java EE Security API 的重大更新而来,它以新名称发布: Jakarta Security 3.0。

Jakarta 安全规范围绕以下新概念定义了新的术语:

  • 认证机制:由调用者调用以获取其凭证,并且对其进行校验,以验证其与身份存储中现有凭证的匹配性
  • 调用者:发起对 API 的调用的主体(用户或服务)
  • 身份存储:控制对 API 的访问的软件组件,通过凭证、角色组和权限进行控制
Jakarta Security 与其他 2 个重要规范进行交互,具体如下:
  • Jakarta 授权(原名 JSR-115: JACC - 用于容器的 Java 授权合同)
  • Jakarta 鉴权(原名 JASPIC - 用于容器的 Java 鉴权 SPI)

正如 Jakarta 安全规范所定义的 授权机制,指的是与一个 调用者 和容器环境进行交互,以获取凭证,验证并传递身份信息(如用户或组名)给容器的控制器。为了验证凭证,授权机制使用 身份存储。规范为文件、RDBMS(关系数据库管理系统)和 LDAP(轻量级目录访问协议)服务器定义了内置的身份存储,也可以自定义身份存储

在本博客中,我们将介绍如何使用 Jakarta Security 内置的 RDBMS 和基于 LDAP 的身份存储来保护 Java web 应用程序。我们选择了 Payara 作为 Jakarta EE 平台,但是不管 Jakarta EE 兼容实现是什么,这个过程都应该是一样的。

一个常见的使用案例

用来举例说明我们故事情节的项目可以在这里找到。它被构建成一个 maven 项目,每个演示内置身份存储的模块都有自己的独立模块,具体如下:

  • 一个名为 jsr-375 的聚合 POM
  • 一个称为 servlet-with-ldap-identity-store 的 WAR 构件,演示基于 LDAP 的内置身份存储
  • 一个称为 servlet-with-jdbc-identity-store 的 WAR 构件,演示基于 LDAP 的内置身份存储
  • 一个名为 platform 的基础设施项目,依赖于 testcontainers,以便运行两个 Payara 平台的实例,一个是服务器,一个是微服务,每个实例都部署了上述两个 WAR

基础设施

如上所述,我们的示例应用部署在 Payara 服务器和 Payara 微服务上。为了做到这一点,我们运行了两个 Docker 容器:一个用于 Payara 服务器实例,一个用于 Payara 微服务实例。我们需要协调这些容器,因此我们将使用 docker-compose 实用程序。以下是相应 YAML 文件的摘录:

version: '3.6'
services:
  payara-micro:
    container_name: payara-micro
    image: payara/micro:latest
    ports:
      - 28080:8080
      - 26900:6900
    expose:
      - 8080
      - 6900
    volumes:
      - ../../../../servlet-with-ldap-identity-store/target/servlet-with-ldap-identity-store.war:/opt/payara/deployments/servlet-with-ldap-identity-store.war
      - ../../../../servlet-with-jdbc-identity-store/target/servlet-with-jdbc-identity-store.war:/opt/payara/deployments/servlet-with-jdbc-identity-store.war
  payara-full:
    container_name: payara-full
    image: payara/server-full:latest
    ports:
      - 18080:8080
      - 18081:8081
      - 14848:4848
      - 19009:9009
    expose:
      - 8080
      - 8081
      - 4848
      - 9009
    volumes:
      - ../../../../servlet-with-ldap-identity-store/target/servlet-with-ldap-identity-store.war:/opt/payara/deployments/servlet-with-ldap-identity-store.war
      - ../../../../servlet-with-jdbc-identity-store/target/servlet-with-jdbc-identity-store.war:/opt/payara/deployments/servlet-with-jdbc-identity-store.war
      - ./scripts/init.sql:/opt/payara/init.sql


正如我们在上面的 docker-compose.yaml 文件中所看到的,以下服务作为 Docker 容器启动:

  • 一个名为payara-micro的服务监听TCP端口28080上的HTTP连接
  • 一个名为payara-full的服务监听TCP端口18080上的HTTP连接

请注意,这两个Payara服务正在将WAR包装载到容器的部署目录中。这会导致给定的WAR被部署。

同时请注意,服务payara-full - 该服务运行Payara服务器,并且承载H2数据库实例 - 也将挂载SQL脚本init.sql,该脚本将用于创建和初始化H2架构,该架构便是我们的身份存储所需的。因此,由Payara服务器托管的H2数据库实例将被payara-fullpayara-micro服务共同使用。

为了运行这些服务,我们使用docker-compose-maven-plugindocker-compose命令。以下是相关POM的摘录:

...
      <plugin>
        <groupId>com.dkanejs.maven.plugins</groupId>
        <artifactId>docker-compose-maven-plugin</artifactId>
        <inherited>false</inherited>
        <executions>
          <execution>
            <id>up</id>
            <phase>install</phase>
            <goals>
              <goal>up</goal>
            </goals>
            <configuration>
              <composeFile>${project.basedir}/src/main/resources/docker-compose.yml</composeFile>
              <detachedMode>true</detachedMode>
              <removeOrphans>true</removeOrphans>
            </configuration>
          </execution>
          <execution>
            <id>down</id>
            <phase>clean</phase>
            <goals>
              <goal>down</goal>
            </goals>
            <configuration>
              <composeFile>${project.basedir}/src/main/resources/docker-compose.yml</composeFile>
              <removeVolumes>true</removeVolumes>
              <removeOrphans>true</removeOrphans>
            </configuration>
          </execution>
        </executions>
      </plugin>
...


在这里,我们将up操作绑定到install阶段,将down操作绑定到clean阶段。这样一来,我们将通过执行mvn install启动容器,通过执行mvn clean停止并移除容器。

RDBMS身份存储

模块servlet-with-jdbc-identity-store,这是我们感兴趣的模块,围绕以下类组织:

  • JdbcIdentityStoreConfig:这是配置类。
  • JdbcIdentitySoreServlet:这是一个演示数据库身份存储的Servlet。
  • JdbcSetup:此类正在设置所需的身份存储架构。

让我们更详细地看看这些类。

类JdbcIdentityStoreConfig

此类定义了我们的RDBMS身份存储的配置。RDBMS身份存储的理念在于,与主体相关的信息存储在关系数据库中。在我们的示例中,这个数据库是与Payara平台一起提供的H2实例。这是一个内存数据库,这里简化起见使用。当然,在生产环境中不应该复制这样的设计,应当使用更适用于生产的数据库,如Oracle、PostgreSQL或MySQL。无论如何,H2架构是由JdbcSetup类创建并初始化的,稍后将对此进行解释。

以下清单显示了代码的摘录:

@ApplicationScoped
@BasicAuthenticationMechanismDefinition(realmName="admin-realm")
@DatabaseIdentityStoreDefinition(
  dataSourceLookup = "${'java:global/H2'}",
  callerQuery = "select password from caller where name = ?",
  groupsQuery = "select group_name from caller_groups where caller_name = ?"
)
public class JdbcIdentityStoreConfig{}


正如我们所看到的,我们的类是一个具有应用程序范围的CDI(上下文和依赖注入)bean。注释@DatabaseIdentityStoreDefinition是新的Jakarta EE注释,定义了数据库身份存储机制。名为dataSourceLookup的参数声明了将获取相关数据源定义的JNDI(Java命名和目录接口)查找名称。一旦找到此数据源引用,我们将执行两个定义的SQL查询,callerQuerygroupsQuery,以查找caller的凭据;即其标识和密码,以及其组成员资格。这里的caller概念在某种程度上等同于user的概念:虽然它不太人性化,但它也可以是一个服务。因此,“它”的代词被使用。

但最有趣的是我们在这里使用了HTTP基本认证机制,由注释@BasicAuthenticationMechanismDefinition定义。这意味着在应用程序启动时,我们将看到一个登录屏幕,并被要求使用用户名和密码进行身份验证。这些信息将进一步传递到数据库身份存储机制,该机制将与数据库中存储的信息进行比较。这样我们就组合了两个JSR-375安全功能,即与数据库身份存储相关的HTTP基本认证。留给读者高抬贵手,这将节省数十行代码的便利性。

类JdbcIdentityStoreServlet

这是一个授权任何具有admin-role角色的调用者访问的Servlet,这是通过Jakarta EE注释@ServletSecurity定义的。另一个特定的Jakarta EE注释是@DeclareRoles,它允许枚举应用程序应该知道的所有可能的角色。

类JdbcSetup

这个类负责创建和初始化数据库身份存储机制所需的数据模型。在它的@PostConstruct方法中,它创建了两个名为callercaller_groups的数据库表。然后,这些表会使用调用者名称、密码和组名称进行初始化。名为admin的调用者隶属于admin-roleuser-role组,而名为user的调用者仅属于user-roles组。

需要注意的是,密码存储在数据库中是经过哈希处理的。Jakarta安全规范定义了接口Pbkdf2PasswordHash,其默认实现基于PBKDF2WithHmacSHA256算法。正如您可以在相关源代码中看到的那样,这个实现可以很简单地注入。在这里,我们使用的是默认实现,在我们的示例中完全令人满意。也可以使用其他更安全的哈希算法,这种情况下Pbkdf2PasswordHash的默认实现可以通过传递包含算法名称以及诸如盐、迭代次数等参数的映射来初始化。Jakarta EE文档详细介绍了所有这些细节。

另一个需要提到的事情是使用Java JDBC(Java数据库连接)代码在运行时单例@PostConstruct方法中初始化数据库可能并不是处理SQL最优雅的方式。这里使用的内存H2数据库接受名为"run script from"的JDBC连接字符串参数,允许我们定义要运行以初始化数据库的脚本。因此,与其在Java JDBC代码中进行操作并且需要提供一个专用的EJB(企业JavaBeans)来做这个事情,我们可以在部署时自动运行初始化SQL脚本。此外,为了处理密码哈希,我们本可以使用H2在最新版本中提供的函数。但是,Payara平台使用的是较旧版本的H2数据库,不支持此功能。因此,为了避免升级Payara平台所附带的H2数据库版本的麻烦,我们最终选择了这种更简单的替代方案。

运行示例

要运行我们的示例,请按照以下步骤进行:

  1. 执行命令mvn clean install。此命令将停止Docker容器(如果正在运行),并启动新的实例。它还将运行预期成功的集成测试。
  2. 集成测试已经在使用testcontainers启动的Docker容器中测试了服务。但是您现在可以在更适合生产环境的容器上进行测试,比如由平台Maven模块管理的容器。您可以运行以下命令来测试Payara Server和Payara Micro:
curl http://localhost:18080/servlet-with-jdbc-identity-store/secured -u "admin:passadmin"


curl http://localhost:28080/servlet-with-jdbc-identity-store/secured -u "admin:passadmin"


LDAP身份存储

使用关系数据库存储安全主体相关信息是一种相当常见的做法;然而,这些数据库并不一定适合此类用例。通常情况下,组织使用Microsoft ActiveDirectory来存储用户、组和相关凭据和其他信息的角色相关信息。虽然我们在示例中可以使用ActiveDirectory或任意其他类似的LDAP实现(例如,Apache DS),但这样的基础架构可能会过于庞大和复杂。因此,为了避免这种情况,我们选择使用内存中的LDAP服务器。

有几种开源LDAP内存实现,其中最合适的之一是Java的UnboundID LDAP SDK。为了使用它,我们只需要一个专用的Maven插件,如下所示:

    <dependency>
      <groupId>com.unboundid</groupId>
      <artifactId>unboundid-ldapsdk</artifactId>
    </dependency>


我们还需要在LDIF(LDAP数据交换格式)文件中定义我们的架构,该文件将被加载到内存中的目录中。例如,我们定义了两个名为adminuser的主体。admin主体拥有admin-roleuser-role角色,而user主体只有user-role角色。以下是所需的LDIF表示法:

...
dn: uid=admin,ou=caller,dc=payara,dc=fish
objectclass: top
objectclass: uidObject
objectclass: person
uid: admin
cn: Administrator
sn: Admin
userPassword: passadmin

dn: uid=user,ou=caller,dc=payara,dc=fish
objectclass: top
objectclass: uidObject
objectclass: person
uid: user
cn: User
sn: User
userPassword: passuser
...
dn: cn=admin-role,ou=group,dc=payara,dc=fish
objectclass: top
objectclass: groupOfNames
cn: admin-role
member: uid=admin,ou=caller,dc=payara,dc=fish

dn: cn=user-role,ou=group,dc=payara,dc=fish
objectclass: top
objectclass: groupOfNames
cn: user-role
member: uid=admin,ou=caller,dc=payara,dc=fish
member: uid=user,ou=caller,dc=payara,dc=fish
...


这里感兴趣的模块servlet-with-ldap-identity-store,围绕以下类组织:

  • LdapIdentityStoreConfig:这是配置类。
  • LdapIdentitySoreServlet:这是一个演示基于数据库身份存储的身份验证的Servlet。
  • LdapSetup:这个类正在设置所需的身份存储架构。

让我们更详细地看一下这些类。

类LdapIdentityStoreConfig

此类定义我们基于LDAP的身份存储的配置。以下是代码摘录:

@ApplicationScoped
@BasicAuthenticationMechanismDefinition(realmName="admin-realm")
@LdapIdentityStoreDefinition(
  url = "ldap://localhost:33389",
  callerBaseDn = "ou=caller,dc=payara,dc=fish",
  groupSearchBase = "ou=group,dc=payara,dc=fish")
public class LdapIdentityStoreConfig{}


如前所述,我们正在使用HTTP基本身份验证。这非常方便,因为浏览器会显示一个登录界面,允许您输入用户名和相关密码。此外,这些凭据将被用来对存储在我们的LDAP服务中的凭据进行身份验证,该服务侦听容器的33389 TCP端口上的连接。 callerBaseDN参数定义了调用者的名称,而groupSearchBase指定了用于查找用户所属组的LDAP查询。

类LdapIdentityStoreServlet

我们的servlet是受保护的,只有具有admin-role角色的主体才被授权。

@WebServlet("/secured")
@DeclareRoles({ "admin-role", "user-role" })
@ServletSecurity(@HttpConstraint(rolesAllowed = "admin-role"))
public class LdapIdentityStoreServlet extends HttpServlet
{
  @Override
  protected void doGet(HttpServletRequest request, HttpServletResponse response)
    throws IOException
  {
    response.getWriter().write("This is a secured servlet \n");
    Principal principal = request.getUserPrincipal();
    String user = principal == null ? null : principal.getName();
    response.getWriter().write("User name: " + user + "\n");
    response.getWriter().write("\thas role \"admin-role\": " + request.isUserInRole("admin-role") + "\n");
    response.getWriter().write("\thas role \"user-role\": " + request.isUserInRole("user-role") + "\n");
  }
}


我们使用@WebServlet注解来声明我们的类为servlet。 @ServletSecurity注解在这里表示只有具有admin-role角色的用户被允许。

类LdapSetup

最后但同样重要的是,类LdapSetup实例化和初始化内存中的LDAP服务:

@Startup
@Singleton
public class LdapSetup
{
  private InMemoryDirectoryServer directoryServer;

  @PostConstruct
  public void init()
  {
    try
    {
      InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig("dc=fish");
      config.setListenerConfigs(
        new InMemoryListenerConfig("myListener", null, 33389, null, null, null));
      directoryServer = new InMemoryDirectoryServer(config);
      directoryServer.importFromLDIF(true,
        new LDIFReader(this.getClass().getResourceAsStream("/users.ldif")));
      directoryServer.startListening();
    } catch (LDAPException e)
    {
      throw new IllegalStateException(e);
    }
  }

  @PreDestroy
  public void destroy()
  {
    directoryServer.shutDown(true);
  }
}


这是一个Startup(在启动时自动运行)CDI bean,具有Singleton作用域。 它通过加载上面显示的LDIF文件来实例化内存中的目录服务器,并开始侦听localhost的TCP端口号33389上的LDAP请求。

测试

提供了一个集成测试,可使用failsafe Maven插件执行。在Maven的集成测试阶段,此集成测试使用testcontainers来创建运行Payara Micro映像并将我们的WAR部署到其中的Docker容器。以下是使用testcontainers进行集成测试的摘录:

  @Container
  private static GenericContainer payara =
    new GenericContainer("payara/micro:latest")
      .withExposedPorts(8080)
      .withCopyFileToContainer(MountableFile.forHostPath(
        Paths.get("target/servlet-with-ldap-identity-store.war")
          .toAbsolutePath(), 0777), "/opt/payara/deployments/test.war")
      .waitingFor(Wait.forLogMessage(".* Payara Micro .* ready in .*\\s", 1))
      .withCommand(
        "--noCluster --deploy /opt/payara/deployments/test.war --contextRoot /test");


这里我们创建一个运行payara/micro:latest映像并暴露TCP端口8080的Docker容器。 我们还将WAR复制到映像中,该WAR是在Maven的打包阶段构建的,最后启动容器。 由于Payara Micro可能需要几秒钟才能启动,因此我们需要等待直到其完全启动。 有几种等待服务器启动完成的方法,但在这里我们使用扫描日志文件的方法,直到显示包含"Payara Micro is ready"的消息。

最后但同样重要的是,使用REST assured库轻松测试部署的servlet,如下所示:

@Test
public void testGetSecuredPageShouldSucceed() throws IOException
{
  given()
   .contentType(ContentType.TEXT)
   .auth().basic("admin", "passadmin")
   .when()
   .get(uri)
   .then()
   .assertThat().statusCode(200)
   .and()
   .body(containsString("admin-role"))
   .and()
   .body(containsString("admin-role"));
}


运行

为了运行应用程序,请遵循以下步骤:

  1. 执行命令mvn clean install。 这个命令将停止Docker容器(如果正在运行),并启动新的实例。 它还将运行应该成功的集成测试。
  2. 集成测试已经在使用testcontainers启动的Docker容器中测试了服务。 但现在您可以在更适用于生产的容器上进行测试,比如由平台Maven模块管理的容器。 要在Payara服务器上进行测试,可以运行像这样的命令:
curl http://localhost:18080/servlet-with-ldap-identity-store/secured -u "admin:passadmin"


运行以下命令以在Payara Micro上进行测试。

curl http://localhost:28080/servlet-with-ldap-identity-store/secured -u "admin:passadmin"


祝您愉快!

推荐阅读: 阿里巴巴面经(69)

本文链接: Jakarta EE安全性:使用身份存储