一笔采购,两个完全不同的“身份”

采购系统现有 POST /procurement/api/lab/requests 接收 X-Lab-Tenant: tenant-one。发送者改为 tenant-two,服务器就按另一个租户操作:这只是隔离实验的输入参数,不是登录,不证明用户属于该租户。JAVAEE_DEMO_MODE=true 只让教学路径可用,也不是安全开关。第 00–05 篇建立的 WAR、CDI 用例和 JDBC 适配器均未接入实际认证。不能把第 05 篇的 DataSource 正常连接解释为有身份的审批已成功。本章描述替换入口所需的契约;未修改累计工程,认证、注销、会话更新及失败登录均 NOT_RUN。

采购员张某(示例标识 buyer-17)创建草稿时,服务器至少要回答三个不同问题:请求是否被认证,认证后的稳定主体标识是什么,该主体此刻能在什么组织内操作。密码只解决第一个问题的一种实现,Principal#getName() 不自动说明租户、审批额度或所有权。应在受信身份存储中维护 user_id、启用状态与角色/组织关联,以认证结果中的稳定主体 ID 找到用户,再由服务端解析租户;客户端不能自行选择有权访问的组织。

认证链分层

1
2
3
浏览器凭据 -> HTTPS -> Jakarta Security 认证机制 -> IdentityStore 验证凭据
-> 容器安全上下文 Principal + 组
-> 业务入口按 principal 查询组织关联 -> 授权检查 -> SQL

Jakarta Security 提供 HttpAuthenticationMechanism 与 IdentityStore 的协议接点;机制决定 HTTP 挑战和会话流程,存储校验凭据并返回身份及组。业务用例不能通过读取自定义请求头来模拟 SecurityContext。密码存储用带随机盐和工作因子的自适应散列(例如 Jakarta Security 的 Pbkdf2PasswordHash),绝不存明文或仅用快速摘要;生产参数、升级策略与口令重置须另行评审。比对失败不返回“用户不存在”与“密码错”两种可枚举信息,限制登录尝试,凭据及完整令牌不进入日志。HTTP Basic 可作无状态实验,但浏览器审批页需要会话方案及 CSRF 防护(第 27 篇),不能因为浏览器关窗就认为已经注销。

以下是目标受管入口的最小接线示意,不是现存代码;userDirectory 必须来自受信身份库,不能实现为读取 X-Lab-Tenant。此片段假设配置了 Basic 挑战(浏览器会话应另选适当的认证机制),注入 SecurityContext 仅取容器认证结果;匿名或目录里已禁用的用户一律拒绝。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package blog.javaee.web;

import jakarta.inject.Inject;
import jakarta.security.enterprise.SecurityContext;
import jakarta.ws.rs.ForbiddenException;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotAuthorizedException;
import jakarta.ws.rs.Path;
import java.util.Map;

@Path("/requests/me")
public class ProcurementIdentityResource {
@Inject SecurityContext security;
@Inject UserDirectory userDirectory;

@GET
public Map<String, String> current() {
if (security.getCallerPrincipal() == null) throw new NotAuthorizedException("Basic realm=\"procurement\"");
UserRecord user = userDirectory.byPrincipal(security.getCallerPrincipal().getName());
if (user == null || !user.enabled()) throw new ForbiddenException();
return Map.of("userId", user.id());
}
}

UserDirectory、UserRecord 和身份存储尚不存在,因此该片段只是入口契约,不能单独编译,更不能当作已部署端点。下一步必须决定 principal 的稳定命名、不可信输入的映射方式及组变更何时生效;不同实现的会话与缓存行为需在所选服务器上检查。受管身份建立后仍需要第 25 篇的方法/对象检查和第 26 篇的租户约束。JDBC 的 javax.sql.DataSource 是 Java SE API,不能在 javax 到 jakarta 的迁移中改成 jakarta.sql。

正常与失败怎样观察

将来接入受管端点后,先隔离部署、创建合成账户,记录一次正确登录后 /api/requests/me 返回主体 ID 与 HTTP 状态;再用错误密码/匿名请求验证 401 挑战与不泄漏身份,用禁用账户验证拒绝。会话方案还需登录前后会话 ID 更新、注销后旧凭证或旧会话不能复用;检查实际响应 cookie 的 Secure、HttpOnly、SameSite 策略及 HTTPS 强制配置。服务器认证失败可发生在资源方法执行之前,不要把 401 与业务层抛出的 403 混作一种情况。对失败响应只保留脱敏状态和关联 ID,不能保留口令或原始 Cookie。

当前不提供伪造“成功认证”的 curl 命令:/api/requests/me 尚不存在,现有 /api/lab/requests 可通过伪造 X-Lab-Tenant 访问,无法充当正常或失败认证验收。**正常登录、错误密码、注销、会话轮换:NOT_RUN(身份存储与新端点均未实现)。**可以在现有工程中核对演示入口:rg -n 'X-Lab-Tenant|JAVAEE_DEMO_MODE' examples/javaee-enterprise/webapp/src/main/java;它是代码定位,不是认证测试。

凭据、主体和组织关联的三个失效窗口

受管认证只回答当前请求能否被识别以及识别为谁。用密码通过 IdentityStore 的一次校验,不意味着账户永远有效:禁用账户、撤销角色、移出部门之后,旧会话可能依旧持有先前建立的主体。业务用例的组织映射需要明确生效时间与缓存边界。若“人员从租户 A 移到 B”是高风险即时操作,仅依赖登录时复制到 Session 的组织列表会允许旧会话继续访问 A;可以对每次敏感审批从受信目录读取当前授权,或建立带明确版本、失效和撤销策略的缓存。二者都不是 Jakarta Security 自动替业务作出的决定,必须以撤销后的旧会话请求验证。

即使账户仍启用,Principal#getName() 的取值也可能随着身份存储配置变化:若把可改的邮箱或花名当成内部主键,改名后申请创建人、审批人和审计记录可能指向错误对象。身份机制应输出在本系统中稳定且不可复用的主体标识,业务库用它关联账户状态和组织关系;用户界面显示名只能展示,不能作 SQL 授权键。多租户主体可以拥有多个组织成员资格,但客户端发来的“希望用哪个租户”只是候选值,服务器须先对照可信成员表,再选择一个当前受权租户。组织查不到时拒绝,不能回落到请求头或默认公共租户。

设计迁移时可以从当前纯业务表旁独立建立身份映射,而非把演示租户头改一个名字就算完成。以下是拟议结构、非现有 schema,NOT_RUN;真实系统还须为密码散列配置存储格式、算法参数和重置流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CREATE TABLE application_user (
user_id varchar(128) PRIMARY KEY,
principal_name varchar(255) NOT NULL UNIQUE,
enabled boolean NOT NULL
);
CREATE TABLE tenant_membership (
user_id varchar(128) NOT NULL REFERENCES application_user(user_id),
tenant_id varchar(255) NOT NULL,
enabled boolean NOT NULL,
PRIMARY KEY (user_id, tenant_id)
);
SELECT membership.tenant_id FROM tenant_membership AS membership
JOIN application_user AS actor ON actor.user_id = membership.user_id
WHERE actor.principal_name = ? AND actor.enabled = TRUE
AND membership.enabled = TRUE AND membership.tenant_id = ?;

这里的 ? 必须由预备语句绑定;第一个值来自容器认证后的主体,第二个是多租户账户请求选择的候选租户。查询无行必须拒绝;同名主体不可被两个账户复用。表中故意没有把角色与金额规则挤进用户行,它们属于后续对象授权。若业务用户表与 IdentityStore 并非同一物理存储,认证成功而映射查询失败仍须拒绝操作;不能用“身份库可用”代替业务映射一致。反向故障也要测:业务库里仍有启用账户,但密码校验失败,不得凭用户表单提供的 user_id 直接进入业务用例。

登录成功和会话有效是两个不同观察

无状态 HTTP Basic 会在每次请求携带凭据,关闭浏览器或让服务器失效一个 Session 都不代表客户端不再持有 Authorization;若要验证“注销后不得复用”,必须先明确使用的是哪个认证机制。对 Cookie 会话,登录前已知的会话 ID 不应在认证成功后继续代表已认证状态,必须按机制更新身份关联并轮换 ID;登出要使服务端凭据关联失效。旧 Cookie 再请求应获得挑战或拒绝,新 Cookie 仍需通过正常授权。HttpOnly 减少浏览器脚本直接读 Cookie 的机会,Secure 约束 HTTPS 传输,SameSite 只是跨站发送策略的一部分,三者都不能代替用户身份验证、会话更新或第 27 篇的 CSRF 校验。

失败登录也不仅是一个 401。未知用户和错误口令若返回不同错误文字或处理时延,攻击者可枚举账户;应使对外提示统一并施加速率限制,检查日志不包含提交的密码、Authorization 或完整 Set-Cookie。口令存储不得用单次 SHA-256 替代自适应散列;盐、工作因子、升级与重置属于身份存储部署策略,不能从一行 @Inject SecurityContext 推出已经启用。账号冻结之后已有请求是否完成、旧会话何时失效,是明确的业务时序选择;无论选哪种策略,验收必须给出基于真实账号的时间线,而不是仅查 enabled=false 的 SQL 字段。

可执行条件与尚不存在的 URL

将来在隔离运行时实现 Jakarta Security 的受管机制、身份库、TLS 与 /api/requests/me 后,可用受信任测试证书及合成账号复跑以下待实施合同。第一个请求让 curl 交互读取口令,避免口令出现在命令行或录制的日志里;第二个只观测匿名挑战,并以脱敏的状态码和响应头为证据,不能把测试密码填进文章:

1
2
curl --fail-with-body -i -u buyer-17 https://localhost:9443/procurement/api/requests/me
curl -i https://localhost:9443/procurement/api/requests/me

证书必须可信且名称匹配,端口和认证方案须以当次隔离部署的实际配置为准;当前 deploy/server.xml 与 WAR 没有这些正式入口,以上命令当前不可能获得预期的主体响应,仍为 NOT_RUN。配置会话认证后,另外记录登录前/后的会话 ID 的脱敏比较、登出前/后旧 Cookie 的请求结果、已禁用账户在旧会话里的请求结果。不要用 Basic 的“浏览器关窗”充当注销实验,也不要将容器返回的统一 401 写成业务自己查过组织归属的证据。正常与失败应保存同版本 WAR、IdentityStore 配置摘要、服务器日志片段及最终业务库行,才能排除“请求到了演示 URL”的错觉。

演示路径与正式路径切换时不能共享信任假设

现有代码用 JAVAEE_DEMO_MODE=true 才暴露 /api/lab/requests,且资源方法从 X-Lab-Tenant 接受客户端自填租户。实施真实认证时,应为正式路由引入独立的受管身份获取与组织解析路径,而非在旧演示资源上“若有 principal 就用它,否则退回请求头”。一条带回退的分支会让测试环境轻易误认为权限已配置;部署配置一旦误开演示模式,匿名请求仍可能完成采购操作。正式版本须在应用层明示关闭或隔离演示路由,除非有明确的测试隔离开关、回环访问限制和禁止带入公网的部署校验。健康检查可保持匿名,但它的 ready 也不应泄漏账户、租户或内部连接信息。

测试时可以先核对旧入口允许任意演示头这一已实现的有限事实,再在全新受保护路径验证未登录请求不进入 ProcurementUseCases、登录主体的用户 ID 与数据库映射一致。两种入口不得复用同一“成功 201”作为证明:演示成功依赖客户端自填值,正式成功必须有真实 principal 和受信组织关联。若有人在正式页面把主体 ID 放到 JSON 体再通过 UserDirectory 查询,实际上仍是在信客户端指定用户;受信主体只能来自已生效的安全上下文。日志里应有可关联的匿名拒绝事件和已认证主体的脱敏 ID,但不可打印凭据以便“证明登录成功”。

撤销实验的顺序也需写清:先用合成账户成功访问自己的采购对象并记录响应;禁用该账户或撤销其组织成员资格;保留原 Cookie/Basic 客户端再次请求。若用的是 Basic,新请求可能重新校验凭据并立即被拒;若用会话,容器可能仍持有旧主体,业务映射层是否重新查询关联决定对象操作是否还能继续。最后换新会话重新登录核对禁用结果,再从第三连接查申请/订单无非法变化。仅在数据库把 enabled 置为 false 而从未用旧凭据复发请求,不足以验证安全策略对已建立会话生效。

密码校验链与数据库提交链别混在一起

认证的速率限制、口令散列升级和账户锁定属于受管身份存储及其部署策略;采购事务开始之前必须先得到认证结果,但采购 @Transactional 回滚不会让已经发生的失败登录计数自动撤销。口令重置后是否需要注销已有会话,也不由采购订单的事务属性决定。反过来,认证成功后下单的 SQL 违反约束,不能据此推断用户名或密码错误。把身份入口、业务授权、受管事务、HTTP 响应四个阶段分别记录,才能定位“用户不能批准”的实际环节,而不是让所有失败都落到同一条笼统的 403 日志。

本篇的准备条件、匿名与旧会话负例及应留存的脱敏证据列在认证实验卡;卡片同样不代表部署记录。

两道有解的练习

练习一: 客户端携 X-Lab-Tenant: tenant-two 获得 204,能否说 tenant-two 的采购员通过认证?怎样改造最小入口?

解: 不能。该值由客户端任意填写且演示入口不校验 principal。关闭公网演示路径,配置受管认证机制及可信身份存储,从安全上下文获得主体,用主体查询受信组织关联;对无身份和无关联的请求默认拒绝。直到新入口实际返回并核对请求与数据库归属,认证与跨租户实验仍为 NOT_RUN。

练习二: 登录成功后调用 request.getSession().invalidate(),浏览器再带旧 Cookie 请求成功。是否已经注销?

解: 不能断言。需确认被注销的是认证所用会话,服务器未以别的凭据(如 Authorization 头或记住登录令牌)重新认证,并检查失效与会话轮换策略;以旧 Cookie 的重复请求应按契约被拒或重新挑战。此处是验收设计,未实际登录、注销或重放。

版本与依据

教学基线是 JDK 21、Jakarta EE 11 Platform、Open Liberty 26.0.0.5;Jakarta EE 11 最低 Java SE 17,不等于本项目的 JDK 21 选择。规范入口:Jakarta Security 4.0、Jakarta Servlet 6.1、Jakarta EE Platform 11 及 Java SE 21 javax.sql。Java EE 8 的企业 API 使用 javax.*;EE 9 起相关 API 迁至 jakarta.*,不包括 Java SE 的 javax.sql。本章认证示意未做编译、部署或安全审计;规范定义接口,不替某一服务器的真实认证记录作证。