当前位置:Java -> 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 安全规范围绕以下新概念定义了新的术语:
正如 Jakarta 安全规范所定义的 授权机制,指的是与一个 调用者 和容器环境进行交互,以获取凭证,验证并传递身份信息(如用户或组名)给容器的控制器。为了验证凭证,授权机制使用 身份存储。规范为文件、RDBMS(关系数据库管理系统)和 LDAP(轻量级目录访问协议)服务器定义了内置的身份存储,也可以自定义身份存储。
在本博客中,我们将介绍如何使用 Jakarta Security 内置的 RDBMS 和基于 LDAP 的身份存储来保护 Java web 应用程序。我们选择了 Payara 作为 Jakarta EE 平台,但是不管 Jakarta EE 兼容实现是什么,这个过程都应该是一样的。
用来举例说明我们故事情节的项目可以在这里找到。它被构建成一个 maven
项目,每个演示内置身份存储的模块都有自己的独立模块,具体如下:
jsr-375
的聚合 POMservlet-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-full
和payara-micro
服务共同使用。
为了运行这些服务,我们使用docker-compose-maven-plugin
的docker-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
停止并移除容器。
模块servlet-with-jdbc-identity-store
,这是我们感兴趣的模块,围绕以下类组织:
JdbcIdentityStoreConfig
:这是配置类。JdbcIdentitySoreServlet
:这是一个演示数据库身份存储的Servlet。JdbcSetup
:此类正在设置所需的身份存储架构。让我们更详细地看看这些类。
此类定义了我们的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查询,callerQuery
和groupsQuery
,以查找caller
的凭据;即其标识和密码,以及其组成员资格。这里的caller
概念在某种程度上等同于user
的概念:虽然它不太人性化,但它也可以是一个服务。因此,“它”的代词被使用。
但最有趣的是我们在这里使用了HTTP基本认证机制,由注释@BasicAuthenticationMechanismDefinition
定义。这意味着在应用程序启动时,我们将看到一个登录屏幕,并被要求使用用户名和密码进行身份验证。这些信息将进一步传递到数据库身份存储机制,该机制将与数据库中存储的信息进行比较。这样我们就组合了两个JSR-375安全功能,即与数据库身份存储相关的HTTP基本认证。留给读者高抬贵手,这将节省数十行代码的便利性。
这是一个授权任何具有admin-role
角色的调用者访问的Servlet,这是通过Jakarta EE注释@ServletSecurity
定义的。另一个特定的Jakarta EE注释是@DeclareRoles
,它允许枚举应用程序应该知道的所有可能的角色。
这个类负责创建和初始化数据库身份存储机制所需的数据模型。在它的@PostConstruct
方法中,它创建了两个名为caller
和caller_groups
的数据库表。然后,这些表会使用调用者名称、密码和组名称进行初始化。名为admin
的调用者隶属于admin-role
和user-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在最新版本中提供的
要运行我们的示例,请按照以下步骤进行:
mvn clean install
。此命令将停止Docker容器(如果正在运行),并启动新的实例。它还将运行预期成功的集成测试。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"
使用关系数据库存储安全主体相关信息是一种相当常见的做法;然而,这些数据库并不一定适合此类用例。通常情况下,组织使用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数据交换格式)文件中定义我们的架构,该文件将被加载到内存中的目录中。例如,我们定义了两个名为admin
和user
的主体。admin
主体拥有admin-role
和user-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
:这个类正在设置所需的身份存储架构。让我们更详细地看一下这些类。
此类定义我们基于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查询。
我们的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
实例化和初始化内存中的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"));
}
为了运行应用程序,请遵循以下步骤:
mvn clean install
。 这个命令将停止Docker容器(如果正在运行),并启动新的实例。 它还将运行应该成功的集成测试。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安全性:使用身份存储