From e713d92d49ae4741812462462964510c427c5327 Mon Sep 17 00:00:00 2001 From: chenxing Date: Fri, 30 Oct 2026 10:21:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(mobile-auth):=20MOB-AUTH-03/04=20=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E8=AE=A4=E8=AF=81=E5=85=A5=E5=BA=93=EF=BC=88=E5=90=AB?= =?UTF-8?q?=20StringRedisTemplate=20@Qualifier=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mobile/ 包:移动身份服务适配(verifyPassword/session 快照/授权种子) - MobileStrongConfiguration:@Qualifier("stringRedisTemplate") 修复双 bean 歧义导致的 /oapi/mobile/* 静默 500 - PasswordVerificationController:POST /oapi/user/verifyPassword(classic app-auth + portal 白名单) - ApplicationKeyReservation(+Repository)、AppController/Identity/ChannelBind oapi 适配、app_code 守卫配套 - MOB_AUTH_03_schema_DRAFT.sql(8 表 DRAFT)、application_bootstrap.sql 100000 幂等 - 测试:PasswordVerificationControllerTest、ApplicationKeyGateTest、AppControllerActivatedTest、entity/mobile 测试 - 依赖:com.yunhai.platform:yh-mobile-auth:1.1.0-mobauth03-rc.1(Nexus 已有同字节制品) --- pom.xml | 7 + .../config/MobileStrongConfiguration.java | 29 +++ .../OpenApiAuthEnforceConfiguration.java | 4 +- .../controller/oapi/AppController.java | 30 +++ .../oapi/ChannelBindController.java | 84 ++----- .../controller/oapi/IdentityController.java | 104 +------- .../oapi/PasswordVerificationController.java | 39 +++ .../userbase/entity/ApplicationInfo.java | 15 +- .../entity/ApplicationKeyReservation.java | 18 ++ .../mobile/MobileIdentityConfiguration.java | 46 ++++ .../mobile/MobileIdentityService.java | 236 ++++++++++++++++++ .../mobile/MobileProofController.java | 27 ++ .../userbase/mobile/MobileSecurityAdvice.java | 18 ++ .../mobile/MobileVersionListener.java | 19 ++ .../model/application/ApplicationInfoVo.java | 3 + .../repository/ApplicationInfoRepository.java | 11 +- .../ApplicationKeyReservationRepository.java | 4 + .../service/IApplicationInfoService.java | 2 +- .../service/impl/ApplicationInfoService.java | 38 ++- .../service/impl/IdentityService.java | 4 + ...026_application_source_principal_draft.sql | 93 +++++++ .../sql/MOB_AUTH_03_schema_DRAFT.sql | 39 +++ .../resources/sql/application_bootstrap.sql | 4 +- .../oapi/AppControllerActivatedTest.java | 90 +++++++ .../PasswordVerificationControllerTest.java | 20 ++ .../ApplicationSourcePrincipalTest.java | 19 ++ .../mobile/IdentityAuthorityReadTest.java | 22 ++ .../mobile/MobileAuthoritySchoolZeroTest.java | 25 ++ .../mobile/MobileContextVersionTest.java | 13 + .../mobile/MobileIdentityTransactionTest.java | 167 +++++++++++++ .../mobile/MobilePasswordAuthorityTest.java | 38 +++ .../impl/ApplicationInfoAppCodeTest.java | 7 + .../service/impl/ApplicationKeyGateTest.java | 109 ++++++++ 33 files changed, 1207 insertions(+), 177 deletions(-) create mode 100644 src/main/java/com/yunhai/userbase/config/MobileStrongConfiguration.java create mode 100644 src/main/java/com/yunhai/userbase/controller/oapi/PasswordVerificationController.java create mode 100644 src/main/java/com/yunhai/userbase/entity/ApplicationKeyReservation.java create mode 100644 src/main/java/com/yunhai/userbase/mobile/MobileIdentityConfiguration.java create mode 100644 src/main/java/com/yunhai/userbase/mobile/MobileIdentityService.java create mode 100644 src/main/java/com/yunhai/userbase/mobile/MobileProofController.java create mode 100644 src/main/java/com/yunhai/userbase/mobile/MobileSecurityAdvice.java create mode 100644 src/main/java/com/yunhai/userbase/mobile/MobileVersionListener.java create mode 100644 src/main/java/com/yunhai/userbase/repository/ApplicationKeyReservationRepository.java create mode 100644 src/main/resources/sql/20261026_application_source_principal_draft.sql create mode 100644 src/main/resources/sql/MOB_AUTH_03_schema_DRAFT.sql create mode 100644 src/test/java/com/yunhai/userbase/controller/oapi/AppControllerActivatedTest.java create mode 100644 src/test/java/com/yunhai/userbase/controller/oapi/PasswordVerificationControllerTest.java create mode 100644 src/test/java/com/yunhai/userbase/entity/ApplicationSourcePrincipalTest.java create mode 100644 src/test/java/com/yunhai/userbase/mobile/IdentityAuthorityReadTest.java create mode 100644 src/test/java/com/yunhai/userbase/mobile/MobileAuthoritySchoolZeroTest.java create mode 100644 src/test/java/com/yunhai/userbase/mobile/MobileContextVersionTest.java create mode 100644 src/test/java/com/yunhai/userbase/mobile/MobileIdentityTransactionTest.java create mode 100644 src/test/java/com/yunhai/userbase/mobile/MobilePasswordAuthorityTest.java create mode 100644 src/test/java/com/yunhai/userbase/service/impl/ApplicationKeyGateTest.java diff --git a/pom.xml b/pom.xml index 4f3a00c..aaa868e 100644 --- a/pom.xml +++ b/pom.xml @@ -35,6 +35,8 @@ + com.h2databaseh2test + com.yunhai.platformyh-mobile-auth1.1.0-mobauth03-rc.1 org.springframework.boot spring-boot-starter-web @@ -268,6 +270,11 @@ C:/ub-build2/userbase + + + mobauth03 + ${mobauth03.output}/${project.artifactId} + diff --git a/src/main/java/com/yunhai/userbase/config/MobileStrongConfiguration.java b/src/main/java/com/yunhai/userbase/config/MobileStrongConfiguration.java new file mode 100644 index 0000000..764bd2a --- /dev/null +++ b/src/main/java/com/yunhai/userbase/config/MobileStrongConfiguration.java @@ -0,0 +1,29 @@ +package com.yunhai.userbase.config; + +import com.yunhai.platform.mobile.*; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.*; +import org.springframework.core.Ordered; +import org.springframework.data.redis.core.StringRedisTemplate; +import java.time.Clock; + +@Configuration(proxyBeanMethods=false) +public class MobileStrongConfiguration { + @Bean public FilterRegistrationBean userBaseMobileStrong( + @org.springframework.beans.factory.annotation.Qualifier("stringRedisTemplate") ObjectProvider redis, + @Value("${yh.mobile.env:}") String env, + @Value("${yh.mobile.app-id:}") String appId,@Value("${yh.mobile.key-registry-file:}") String keyFile, + @Value("${yh.mobile.key-registry-sha256:}") String keyHash) { + StrongRequestFilter filter=new StrongRequestFilter(()-> { + StringRedisTemplate r=redis.getIfAvailable();if(r==null)return null; + return new RequestAssertion(new PinnedKeyRegistry(keyFile,keyHash),new RedisReplay(r),Clock.systemUTC(),"userbase"); + },r->{String path=r.getRequestURI().substring(r.getContextPath().length());String action=SensitivePaths.action(r.getMethod(),path); + if(action==null)return null; + AuthFailure.require(!env.isEmpty()&&!appId.isEmpty(),503,"CALLER_VERIFIER_UNAVAILABLE"); + return new StrongRequestFilter.Required(env,appId,action,path);}); + FilterRegistrationBean bean=new FilterRegistrationBean<>(filter); + bean.setOrder(Ordered.HIGHEST_PRECEDENCE+3);bean.addUrlPatterns("/*");return bean; + } +} diff --git a/src/main/java/com/yunhai/userbase/config/OpenApiAuthEnforceConfiguration.java b/src/main/java/com/yunhai/userbase/config/OpenApiAuthEnforceConfiguration.java index a1e94b0..97fcb20 100644 --- a/src/main/java/com/yunhai/userbase/config/OpenApiAuthEnforceConfiguration.java +++ b/src/main/java/com/yunhai/userbase/config/OpenApiAuthEnforceConfiguration.java @@ -68,7 +68,9 @@ public class OpenApiAuthEnforceConfiguration { throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse res = (HttpServletResponse) response; - if (req.getAttribute(AppAuthFilter.APP_AUTH_ATTR) == null) { + if (req.getAttribute(AppAuthFilter.APP_AUTH_ATTR) == null + && !(req.getAttribute(com.yunhai.platform.mobile.StrongRequestFilter.VERIFIED) + instanceof com.yunhai.platform.mobile.CallerAppContext)) { res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing app signature"); return; } diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java b/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java index bdb497a..0bab68e 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java @@ -5,11 +5,13 @@ import com.yunhai.platform.utils.enums.IEnum; import com.yunhai.platform.utils.model.BoaAppType; import com.yunhai.userbase.config.Config; import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.model.application.AppActivatedVo; import com.yunhai.userbase.model.application.AppIconMapVo; import com.yunhai.userbase.model.application.ApplicationInfoVo; import com.yunhai.userbase.service.IAppCategoryService; import com.yunhai.userbase.service.IApplicationInfoService; import com.yunhai.userbase.service.IGroupAuthzService; +import com.yunhai.platform.utils.exception.DException; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; @@ -38,6 +40,9 @@ public class AppController { @Autowired private IGroupAuthzService groupAuthzService; + @Autowired + private com.yunhai.userbase.service.ISysGroupService sysGroupService; + /** * 按主键 ID 查询应用信息(带缓存)。
* 与客户端契约 {@code GET /oapi/app/{appId}} 对应;查不到返回 null(HTTP 200 + data:null 信封)。
@@ -158,4 +163,29 @@ public class AppController { public List iconMap() { return applicationInfoService.listIconMap(); } + + /** + * 应用×学校单点开通判定(2026-10-25 T01 最小只读口)。
+ * 权威事实源 = {@code sys_group_app_rel}(组应用开通表,学校组单点、不沿祖先、不并角色模块), + * 与 {@code GET /oapi/app/list-by-school} 同一口径,只是收敛成 boolean 判定—— + * form-center/flow-center 等下游把本端点作为「该应用是否在该校开通」的平台资格第二重证明 + * (fail-closed:本端点不可达时下游必须拒绝,不得降级为"仅验签通过")。
+ * 应用不存在(含已注销)返回 activated=false,不区分"没开通"与"没这应用", + * 避免把应用存在性做成探测面;schoolId 非法(非学校组锚点)按既有 {@code assertSchoolId} + * 口径抛业务错误(code=5)。只读、无敏感字段。 + * + * @param appId 应用主键 ID(必填;稳定主体,非 appKey 凭据索引) + * @param schoolId 学校组 ID(必填) + */ + @GetMapping("activated") + public AppActivatedVo activated(@RequestParam("appId") Integer appId, + @RequestParam("schoolId") Integer schoolId) { + if (appId == null || schoolId == null) { + throw new DException("appId 与 schoolId 必填"); + } + sysGroupService.assertSchoolId(schoolId); + boolean appExists = applicationInfoService.getById(appId) != null; + boolean activated = appExists && groupAuthzService.findAuthorizedAppIds(schoolId).contains(appId); + return new AppActivatedVo(appId, schoolId, activated); + } } diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/ChannelBindController.java b/src/main/java/com/yunhai/userbase/controller/oapi/ChannelBindController.java index 9ec3d27..639adc5 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/ChannelBindController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/ChannelBindController.java @@ -1,71 +1,19 @@ package com.yunhai.userbase.controller.oapi; - -import com.yunhai.platform.utils.model.BoaUser; -import com.yunhai.userbase.common.annotation.OapiLog; -import com.yunhai.userbase.config.Config; -import com.yunhai.userbase.model.channel.ChannelBindByPhoneRequest; -import com.yunhai.userbase.model.channel.ChannelBindRequest; -import com.yunhai.userbase.model.channel.ChannelUnbindRequest; -import com.yunhai.userbase.service.impl.ChannelBindService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -/** - * 移动端渠道绑定开放接口(sys_user_channel_rel,移动端接入 步 3,方案 §3.13)。
- *
- * 供 SDK 登录端点(门户 / 各子应用)经 yh-app-auth 签名调用({@code /oapi/channel/...}); - * 签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制。
- * 绑定身份以服务端校验为准:openid/unionId 来自一次性 loginTicket,账号密码/手机号在本服务校验, - * 前端永不接触 openid/unionid(抢绑防护,方案 §3.4)。
- * POST 写操作由 {@code OapiLogAspect} 自动记录审计日志。 - * - * @author chenx - * @date 2026/9/25 - */ -@RestController -@RequestMapping(Config.OPEN_API_PATH + "channel/") +import com.yunhai.platform.mobile.*; +import com.yunhai.userbase.mobile.MobileIdentityService; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.web.bind.annotation.*; +import java.util.Map; +/** Legacy paths retained; naked phone/account/openid authority is retired. */ +@RestController @RequestMapping("/oapi/channel") public class ChannelBindController { - - @Autowired - private ChannelBindService channelBindService; - - /** - * 按 (channel, openid) 查已绑定用户。 - * - * @return 已绑定时返回用户明细(含 currentRole);未绑定返回 null(data 为 null,code=0) - */ - @GetMapping("user") - public BoaUser user(@RequestParam("channel") String channel, - @RequestParam("openid") String openid) { - return channelBindService.findBoundUser(channel, openid); - } - - /** 凭证绑定:账号密码校验 + 三层查重(openid/账号/unionid)通过后写绑定关系 */ - @OapiLog - @PostMapping("bind") - public BoaUser bind(@RequestBody @Validated ChannelBindRequest request) { - return channelBindService.bindByCredential(request.getChannel(), request.getOpenid(), - request.getUnionId(), request.getAccount(), request.getPassword()); - } - - /** 手机号证明绑定(小程序一键手机号):手机号归属由渠道验签器证明 */ - @OapiLog - @PostMapping("bindByPhone") - public BoaUser bindByPhone(@RequestBody @Validated ChannelBindByPhoneRequest request) { - return channelBindService.bindByPhone(request.getChannel(), request.getOpenid(), - request.getUnionId(), request.getPhone()); - } - - /** 解绑:按登录账号 + 渠道删绑定行(account 来自 token payload,服务端定位) */ - @OapiLog - @PostMapping("unbind") - public void unbind(@RequestBody @Validated ChannelUnbindRequest request) { - channelBindService.unbind(request.getChannel(), request.getAccount()); - } + private final ObjectProvider provider; + public ChannelBindController(ObjectProvider provider){this.provider=provider;} + private MobileIdentityService service(){MobileIdentityService s=provider.getIfAvailable();AuthFailure.require(s!=null,503,"AUTHORITY_UNAVAILABLE");return s;} + public static class Bind {public String channelProof,userProof,operationId;} + public static class Unbind {public String userActionProof,reauthProof,operationId;} + @GetMapping("/user") public Map user(@RequestHeader("X-YH-Proof") String proof){return service().resolve(proof);} + @PostMapping("/bind") public Map bind(@RequestBody Bind b){return service().bind(b.channelProof,b.userProof,b.operationId);} + @PostMapping("/bindByPhone") public void phone(){Contexts.requireCaller("channel.bind.phone");throw new AuthFailure(403,"METHOD_DISABLED");} + @PostMapping("/unbind") public Map unbind(@RequestBody Unbind b){return service().unbind(b.userActionProof,b.reauthProof,b.operationId);} } diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/IdentityController.java b/src/main/java/com/yunhai/userbase/controller/oapi/IdentityController.java index 7dda4e8..38822e8 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/IdentityController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/IdentityController.java @@ -1,94 +1,16 @@ package com.yunhai.userbase.controller.oapi; - -import com.yunhai.platform.utils.exception.DException; -import com.yunhai.platform.utils.model.BoaRoleInfo; -import com.yunhai.userbase.common.context.CallerAppContext; -import com.yunhai.userbase.config.Config; -import com.yunhai.userbase.model.identity.IdentitySwitchOapiRequest; -import com.yunhai.userbase.service.impl.IdentityService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -/** - * 用户身份开放接口:供其他业务服务经 yh-app-auth 签名调用({@code /oapi/identity/...})。
- *
- * 契约统一 {@link BoaRoleInfo}(roleId/roleName/schId/schName/childId/childName), - * 与 {@code /oapi/user/roles}、{@code BoaUser.currentRole} 完全同构,前端零转换。
- * 签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制;
- * 切换(switch)额外受 {@code yh.identity.switch-allowed-app-keys} 白名单约束(AppKey 级闸门), - * 且目标用户必须真实存在于其身份集({@code sys_user_role_rel} 三元组校验,防越权切换他人)。
- * POST 写操作由 {@code OapiLogAspect} 自动记录审计日志。 - * - * @author chenx - * @date 2026/8/27 - */ -@RestController -@RequestMapping(Config.OPEN_API_PATH + "identity/") +import com.yunhai.platform.mobile.*; +import com.yunhai.userbase.mobile.MobileIdentityService; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.web.bind.annotation.*; +import java.util.Map; +@RestController @RequestMapping("/oapi/identity") public class IdentityController { - - @Autowired - private IdentityService identityService; - - /** 允许调用 switch 的应用 appKey 白名单(逗号分隔,缺省空 = 禁闭) */ - @Value("${yh.identity.switch-allowed-app-keys:}") - private String switchAllowedAppKeys; - - /** - * 查询用户当前激活身份:优先 sys_user_current_role,未切换时兜底主身份(动态解析); - * 无任何身份返回 null。 - */ - @GetMapping("current") - public BoaRoleInfo current(@RequestParam("userId") Integer userId) { - return identityService.getCurrent(userId); - } - - /** - * 用户全部可选身份(正常态),按 主身份所在学校置顶 → 其他学校 schoolId 升序 → 校内 roleId 升序, - * 供「切换身份」页渲染。 - */ - @GetMapping("available") - public List available(@RequestParam("userId") Integer userId) { - return identityService.listAvailable(userId); - } - - /** - * 切换身份:AppKey 白名单校验后,校验三元组合法并 UPSERT sys_user_current_role。 - */ - @PostMapping("switch") - public BoaRoleInfo switchIdentity(@RequestBody @Validated IdentitySwitchOapiRequest request) { - checkSwitchAllowed(); - return identityService.switchIdentity(request.getUserId(), request.getRoleId(), request.getSchoolId()); - } - - /** AppKey 级闸门:仅允许白名单内的应用替用户切换身份。 */ - private void checkSwitchAllowed() { - Integer callerAppKey = CallerAppContext.getAppKey(); - if (callerAppKey == null || !parseAllowedAppKeys().contains(callerAppKey)) { - throw new DException("当前应用无权调用身份切换接口"); - } - } - - private Set parseAllowedAppKeys() { - if (switchAllowedAppKeys == null || switchAllowedAppKeys.trim().isEmpty()) { - return new HashSet<>(); - } - return Arrays.stream(switchAllowedAppKeys.split(",")) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .map(Integer::valueOf) - .collect(Collectors.toSet()); - } + private final ObjectProvider provider; + public IdentityController(ObjectProvider provider){this.provider=provider;} + private MobileIdentityService service(){MobileIdentityService s=provider.getIfAvailable();AuthFailure.require(s!=null,503,"AUTHORITY_UNAVAILABLE");return s;} + @GetMapping("/current") public Map current(@RequestHeader("X-YH-Proof") String proof){return service().readIdentity(proof,false);} + @GetMapping("/available") public Map available(@RequestHeader("X-YH-Proof") String proof){return service().readIdentity(proof,true);} + public static class Switch {public String userActionProof,operationId;public int roleId,schoolId;public long expectedIdentityVersion;} + @PostMapping("/switch") public Map change(@RequestBody Switch b){return service().switchIdentity(b.userActionProof,b.roleId,b.schoolId,b.expectedIdentityVersion,b.operationId);} } diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/PasswordVerificationController.java b/src/main/java/com/yunhai/userbase/controller/oapi/PasswordVerificationController.java new file mode 100644 index 0000000..24fee97 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/controller/oapi/PasswordVerificationController.java @@ -0,0 +1,39 @@ +package com.yunhai.userbase.controller.oapi; + +import com.yunhai.platform.appauth.AppAuthFilter; +import com.yunhai.platform.mobile.*; +import com.yunhai.userbase.mobile.MobileIdentityService; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import javax.servlet.http.*; +import java.util.*; + +/** Existing app-sign authentication; explicit portal allowlist. Never log request/credentials. */ +@RestController +@RequestMapping("/oapi/user") +public class PasswordVerificationController { + private final ObjectProvider services; + private final String env; private final Set portals; + public PasswordVerificationController(ObjectProvider services, + @Value("${yh.mobile.env:}") String env, + @Value("${yh.mobile.password-allowed-app-keys:}") String allowed) { + this.services=services;this.env=env;this.portals=new HashSet<>(Arrays.asList(allowed.replace(" ","").split(","))); + } + public static class Credentials { public String account,password; } + @PostMapping("/verifyPassword") + public SessionAuthenticator.Snapshot verify(@RequestBody Credentials b,HttpServletRequest r,HttpServletResponse response) { + response.setHeader("Cache-Control","no-store"); + Object app=r.getAttribute(AppAuthFilter.APP_AUTH_ATTR); + AuthFailure.require(app!=null,401,"APP_SIGNATURE_REQUIRED"); + AuthFailure.require(portals.contains(String.valueOf(app)),403,"PORTAL_CALLER_REQUIRED"); + MobileIdentityService service=services.getIfAvailable();AuthFailure.require(service!=null,503,"AUTHORITY_UNAVAILABLE"); + return service.verifyPortalPassword(env,b.account,b.password); + } + @ExceptionHandler(AuthFailure.class) + public ResponseEntity> failure(AuthFailure e) { + Map result=new LinkedHashMap<>();result.put("code",e.status==401?40101:e.status==403?40301:50301);result.put("msg",e.status==401?"账号或密码错误":e.code); + return ResponseEntity.status("APP_SIGNATURE_REQUIRED".equals(e.code)||"PORTAL_CALLER_REQUIRED".equals(e.code)?e.status:200).header("Cache-Control","no-store").body(result); + } +} diff --git a/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java b/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java index dbad556..5fc9364 100644 --- a/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java +++ b/src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java @@ -26,6 +26,17 @@ import java.util.List; @EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) public class ApplicationInfo extends BaseEntity { + /** Immutable application incarnation; never derive it from reusable numeric IDs or credentials. */ + @Column(name = "source_principal_id", unique = true, updatable = false, length = 36) + private String sourcePrincipalId; + + @javax.persistence.PrePersist + void assignSourcePrincipal() { + // Always generate server-side, including when an entity supplied a value. + sourcePrincipalId = java.util.UUID.randomUUID().toString(); + } + + /** * 应用名称(页面*必填) */ @@ -52,7 +63,7 @@ public class ApplicationInfo extends BaseEntity { * appKey 系统生成唯一标识,整型,从100000起自增。
*/ @NotNull - @Column(name = "app_key", unique = true, nullable = false) + @Column(name = "app_key", unique = true, nullable = false, updatable = false) private Integer appKey; /** @@ -174,4 +185,4 @@ public class ApplicationInfo extends BaseEntity { @Column(name = "push_secret") private String pushSecret; -} \ No newline at end of file +} diff --git a/src/main/java/com/yunhai/userbase/entity/ApplicationKeyReservation.java b/src/main/java/com/yunhai/userbase/entity/ApplicationKeyReservation.java new file mode 100644 index 0000000..45d5433 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/entity/ApplicationKeyReservation.java @@ -0,0 +1,18 @@ +package com.yunhai.userbase.entity; +import javax.persistence.*; + +/** Permanent credential-key tombstone. Application deletion must never delete this row. */ +@Entity @Table(name="application_key_reservation") +public class ApplicationKeyReservation { + /** + * 历史身份审计未完成的机械门禁哨兵(sql/20261026_application_source_principal_draft.sql §Gate): + * 该行存在=禁止发放任何新 appKey。取 -1 而非 0:默认 sql_mode 下字面 0 会被 + * AUTO_INCREMENT 改写为下一个生成值,-1 按字面落库且低于保留段,即使被返回也会被 + * allocateAppKey 的 key<100000 熔断拒绝。 + */ + public static final Integer GATE_CLOSED_SENTINEL = -1; + @Id @GeneratedValue(strategy=GenerationType.IDENTITY) + @Column(name="app_key") + private Integer appKey; + public Integer getAppKey(){return appKey;} +} diff --git a/src/main/java/com/yunhai/userbase/mobile/MobileIdentityConfiguration.java b/src/main/java/com/yunhai/userbase/mobile/MobileIdentityConfiguration.java new file mode 100644 index 0000000..718d85a --- /dev/null +++ b/src/main/java/com/yunhai/userbase/mobile/MobileIdentityConfiguration.java @@ -0,0 +1,46 @@ +package com.yunhai.userbase.mobile; +import com.yunhai.platform.mobile.*; +import com.yunhai.userbase.entity.SysUser; +import com.yunhai.userbase.enums.StatusEnum; +import com.yunhai.userbase.repository.SysUserRepository; +import com.yunhai.userbase.service.impl.IdentityService; +import com.yunhai.platform.utils.model.BoaRoleInfo; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.*; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import java.time.Clock; +@Configuration(proxyBeanMethods=false) +@ConditionalOnProperty(name="yh.mobile.identity-store-enabled",havingValue="true") +public class MobileIdentityConfiguration { + @Bean public MobileIdentityService mobileIdentityService(JdbcTemplate db,PlatformTransactionManager manager, + SysUserRepository users,PasswordEncoder encoder,IdentityService identities,com.yunhai.userbase.service.IGroupAuthzService authorization) { + MobileIdentityService.Accounts accounts=new MobileIdentityService.Accounts() { + public boolean applicationAllowed(int uid,Integer school,String audience){ + if(school==null||school<0||audience==null||!audience.matches("[1-9][0-9]{0,8}"))return false; + if(school==0&&!users.findById(uid).map(u->u.getType()==com.yunhai.userbase.enums.UserTypeEnum.SUPER_ADMIN).orElse(false))return false; + return authorization.findUserAuthorizedAppIds(uid,school).contains(Integer.valueOf(audience)); + } + public int casAccount(String principal) { + AuthFailure.require(users.countByAccount(principal)==1,403,"CAS_USER_UNMAPPED_OR_AMBIGUOUS"); + SysUser u=users.findByAccount(principal);return u.getId(); + } + public int verifyPassword(String account,String password) { + AuthFailure.require(account!=null&&password!=null,401,"PROOF_INVALID");SysUser user=users.findByAccount(account); + AuthFailure.require(user!=null&&encoder.matches(password,user.getPassword())&&user.getDisabled()!=StatusEnum.DISABLED&&!Boolean.TRUE.equals(user.getExpired()),401,"PROOF_INVALID"); + return user.getId(); + } + public SessionAuthenticator.Snapshot snapshot(int uid) { + SysUser u=users.findById(uid).orElseThrow(()->new AuthFailure(401,"USER_REVOKED"));BoaRoleInfo role=identities.getCurrentForAuthentication(uid); + boolean qualified=role!=null&&identities.listAvailable(uid).stream().anyMatch(r->java.util.Objects.equals(r.getSchId(),role.getSchId())&&java.util.Objects.equals(r.getRoleId(),role.getRoleId())); + return new SessionAuthenticator.Snapshot(uid,qualified?role.getSchId():null,qualified?role.getRoleId():null, + u.getDisabled()!=StatusEnum.DISABLED&&!Boolean.TRUE.equals(u.getExpired()),u.getType()==com.yunhai.userbase.enums.UserTypeEnum.SUPER_ADMIN,true,0,0,0,0,u.getAccount(),u.getRealName()); + } + public java.util.List available(int uid){return identities.listAvailable(uid);} + public void switchIdentity(int uid,int role,int school) { identities.switchIdentity(uid,role,school); } + }; + return new MobileIdentityService(db,new TransactionTemplate(manager),accounts,Clock.systemUTC()); + } +} diff --git a/src/main/java/com/yunhai/userbase/mobile/MobileIdentityService.java b/src/main/java/com/yunhai/userbase/mobile/MobileIdentityService.java new file mode 100644 index 0000000..ff64675 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/mobile/MobileIdentityService.java @@ -0,0 +1,236 @@ +package com.yunhai.userbase.mobile; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.platform.mobile.*; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.dao.DataIntegrityViolationException; +import java.time.Clock; +import java.util.*; +import java.util.function.Supplier; + +/** UB-owned scope/proof/ledger state. Constructor collaborators permit isolated DB contract tests. */ +public final class MobileIdentityService { + public interface Accounts { + int verifyPassword(String account,String password); + SessionAuthenticator.Snapshot snapshot(int userId); + void switchIdentity(int userId,int roleId,int schoolId); + default java.util.List available(int uid){throw new AuthFailure(503,"IDENTITY_UNAVAILABLE");} + default int casAccount(String principal){throw new AuthFailure(503,"CAS_MAPPING_NOT_CONFIGURED");} + default boolean applicationAllowed(int userId,Integer school,String audience){return false;} + } + private final JdbcTemplate db;private final TransactionTemplate tx;private final Accounts accounts;private final Clock clock; + private final ObjectMapper json=new ObjectMapper(); + public MobileIdentityService(JdbcTemplate db,TransactionTemplate tx,Accounts accounts,Clock clock) { + this.db=db;this.tx=tx;this.accounts=accounts;this.clock=clock; + } + private CallerAppContext caller(String cap) { return Contexts.requireCaller(cap); } + private void scope(CallerAppContext c,String cap,String providerScope) { + Integer count=db.queryForObject("SELECT COUNT(*) FROM mob_auth_caller_scope WHERE env=? AND app_id=? AND capability=? AND provider_scope=?", + Integer.class,c.env,c.appId,cap,providerScope); + AuthFailure.require(count!=null&&count==1,403,"CALLER_CAPABILITY_DENIED"); + } + public String channelProof(String flow,String providerScope,String subjectHash,long version,String action) { + CallerAppContext c=caller("proof.channel.register");scope(c,"proof.channel.register",providerScope); + AuthFailure.require(subjectHash!=null&&subjectHash.matches("[a-f0-9]{64}")&&Arrays.asList("RESOLVE","BIND").contains(action),400,"PROOF_INVALID"); + long current=subjectVersion(c.env,providerScope,subjectHash);AuthFailure.require(current==version,409,"SUBJECT_STALE"); + return issue(c,action,flow,null,providerScope,subjectHash,version); + } + public String passwordProof(String channelHandle,String account,String password) { + CallerAppContext c=caller("proof.user.issue");Map ch=proof(c,channelHandle,"BIND",false); + int uid=accounts.verifyPassword(account,password);AuthFailure.require(uid>0,401,"PROOF_INVALID"); + return issue(c,"BIND_USER",str(ch,"flow_id"),uid,str(ch,"provider_scope"),str(ch,"provider_subject_hash"),number(ch,"subject_version")); + } + public String actionProof(int userId,String flow,String providerScope,String subject,long version,String action) { + CallerAppContext c=caller("proof.action.register");scope(c,"proof.action.register",providerScope); + AuthFailure.require(Arrays.asList("UNBIND","SWITCH","READ_CURRENT","READ_AVAILABLE").contains(action),400,"PROOF_INVALID"); + AuthFailure.require(accounts.snapshot(userId).active,401,"USER_REVOKED"); + if(!"SWITCH".equals(action)&&!action.startsWith("READ_")) AuthFailure.require(boundUser(c.env,providerScope,subject)==userId,403,"NOT_BINDING_OWNER"); + return issue(c,action,flow,userId,providerScope,subject,version); + } + // This endpoint does not accept a claimed userId: it issues a proof ONLY for the credential-verified user. + public String reauth(String actionHandle,String account,String password) { + CallerAppContext c=caller("proof.user.issue");Map a=proof(c,actionHandle,"UNBIND",false); + int uid=accounts.verifyPassword(account,password);AuthFailure.require(uid==number(a,"user_id"),403,"PROOF_SUBJECT_MISMATCH"); + return issue(c,"UNBIND_REAUTH",str(a,"flow_id"),uid,str(a,"provider_scope"),str(a,"provider_subject_hash"),number(a,"subject_version")); + } + public Map resolve(String handle) { + CallerAppContext c=caller("channel.resolve");return tx.execute(s->{Map p=proof(c,handle,"RESOLVE",true); + scope(c,"channel.resolve",str(p,"provider_scope"));return result(boundUser(c.env,str(p,"provider_scope"),str(p,"provider_subject_hash")));}); + } + public Map bind(String channel,String user,String operationId) { + CallerAppContext c=caller("channel.bind.password");String digest=Crypto.hash("BIND\n"+Crypto.hash(channel)+"\n"+Crypto.hash(user)); + return operation(c,operationId,digest,()-> { + Map ch=proof(c,channel,"BIND",false),p=proof(c,user,"BIND_USER",false);int uid=(int)number(p,"user_id"); + requireSameFlow(ch,p);scope(c,"channel.bind.password",str(ch,"provider_scope")); + lockUser(uid);AuthFailure.require(accounts.snapshot(uid).active,401,"USER_REVOKED"); + String providerScope=str(ch,"provider_scope"),subject=str(ch,"provider_subject_hash");long expected=number(ch,"subject_version"); + AuthFailure.require(subjectVersion(c.env,providerScope,subject)==expected,409,"SUBJECT_STALE"); + int owner=boundUser(c.env,providerScope,subject);AuthFailure.require(owner==0||owner==uid,409,"BINDING_CONFLICT"); + if(expected==0) db.update("INSERT INTO mob_auth_subject(env,provider_scope,provider_subject_hash,user_id,version) VALUES(?,?,?,?,1)",c.env,providerScope,subject,uid); + else db.update("UPDATE mob_auth_subject SET user_id=?,version=version+1 WHERE env=? AND provider_scope=? AND provider_subject_hash=? AND version=?",uid,c.env,providerScope,subject,expected); + consume(c,channel,"BIND");consume(c,user,"BIND_USER");ensureEpoch(c.env,uid);return result(uid); + }); + } + public Map unbind(String action,String reauth,String operationId) { + CallerAppContext c=caller("channel.unbind.self");return operation(c,operationId,Crypto.hash("UNBIND\n"+Crypto.hash(action)+"\n"+Crypto.hash(reauth)),()-> { + Map p=proof(c,action,"UNBIND",false),r=proof(c,reauth,"UNBIND_REAUTH",false);requireSameFlow(p,r); + int uid=(int)number(p,"user_id");AuthFailure.require(uid==number(r,"user_id"),403,"PROOF_SUBJECT_MISMATCH");lockUser(uid); + String providerScope=str(p,"provider_scope"),subject=str(p,"provider_subject_hash");scope(c,"channel.unbind.self",providerScope); + int changed=db.update("UPDATE mob_auth_subject SET user_id=NULL,version=version+1 WHERE env=? AND provider_scope=? AND provider_subject_hash=? AND user_id=? AND version=?", + c.env,providerScope,subject,uid,number(p,"subject_version"));AuthFailure.require(changed==1,409,"SUBJECT_STALE"); + ensureEpoch(c.env,uid);db.update("UPDATE mob_auth_epoch SET user_epoch=user_epoch+1 WHERE env=? AND user_id=?",c.env,uid); + consume(c,action,"UNBIND");consume(c,reauth,"UNBIND_REAUTH");return result(uid); + }); + } + public Map switchIdentity(String handle,int role,int school,long expected,String op) { + CallerAppContext c=caller("identity.switch.self");return operation(c,op,Crypto.hash(Crypto.hash(handle)+"\n"+role+"\n"+school+"\n"+expected),()-> { + Map p=proof(c,handle,"SWITCH",false);int uid=(int)number(p,"user_id");lockUser(uid);ensureEpoch(c.env,uid); + long current=db.queryForObject("SELECT identity_version FROM mob_auth_epoch WHERE env=? AND user_id=?",Long.class,c.env,uid); + AuthFailure.require(current==expected,409,"CONTEXT_STALE");AuthFailure.require(school>0||(school==0&&accounts.snapshot(uid).superAdmin),403,"SCHOOL_CONTEXT_REQUIRED"); + accounts.switchIdentity(uid,role,school); + long updated=db.queryForObject("SELECT identity_version FROM mob_auth_epoch WHERE env=? AND user_id=?",Long.class,c.env,uid); + if(updated==current){db.update("UPDATE mob_auth_epoch SET identity_version=identity_version+1 WHERE env=? AND user_id=?",c.env,uid);updated=current+1;} + AuthFailure.require(updated>current,409,"CONTEXT_STALE"); + consume(c,handle,"SWITCH");Map r=result(uid);r.put("identityVersion",updated);return r; + }); + } + public Map readIdentity(String handle,boolean available) { + CallerAppContext c=caller("identity.read.self");return tx.execute(s->{Map p=proof(c,handle,available?"READ_AVAILABLE":"READ_CURRENT",true); + SessionAuthenticator.Snapshot a=accounts.snapshot((int)number(p,"user_id"));Map r=result(a.userId);if(available)r.put("roles",accounts.available(a.userId));r.put("schId",a.schoolId);r.put("roleId",a.roleId);return r;}); + } + public Map recover(String op,String hash) { + CallerAppContext c=caller("mobile.operation.read");return existing(c,op,hash); + } + public SessionAuthenticator.Snapshot validate(SessionAuthenticator.Session s) { + CallerAppContext c=caller("mobile.identity.validate");AuthFailure.require(c.env.equals(s.env),401,"ENV_MISMATCH"); + SessionAuthenticator.Snapshot user=accounts.snapshot(s.userId); + AuthFailure.require(s.audience!=null&&(s.audience.equals(c.appId)||accounts.applicationAllowed(s.userId,user.schoolId,s.audience)),403,"APPLICATION_DENIED"); + Long generation=db.queryForObject("SELECT generation FROM mob_auth_generation WHERE env=? AND ready=TRUE",Long.class,c.env); + Map epoch=db.queryForMap("SELECT user_epoch,identity_version FROM mob_auth_epoch WHERE env=? AND user_id=?",c.env,s.userId); + long binding=0;boolean bound="h5".equals(s.channel); + if(!bound) { + String scope,subject; + try{com.fasterxml.jackson.databind.JsonNode n=json.readTree(s.subject);AuthFailure.require(n.isArray()&&n.size()==2,401,"CONTEXT_STALE");scope=n.get(0).asText();subject=n.get(1).asText();}catch(Exception e){throw new AuthFailure(401,"CONTEXT_STALE");} + List> rows=db.queryForList("SELECT version FROM mob_auth_subject WHERE env=? AND provider_scope=? AND provider_subject_hash=? AND user_id=?",c.env,scope,subject,s.userId); + AuthFailure.require(rows.size()==1,401,"CONTEXT_STALE");binding=number(rows.get(0),"version");bound=true;} + return new SessionAuthenticator.Snapshot(user.userId,user.schoolId,user.roleId,user.active,user.superAdmin,bound, + number(epoch,"user_epoch"),number(epoch,"identity_version"),binding,generation,user.account,user.name); + } + /** Only called by the app-signed, portal-allowlisted password endpoint. No channel proof required. */ + public SessionAuthenticator.Snapshot verifyPortalPassword(String env,String account,String password) { + AuthFailure.require(env!=null&&!env.trim().isEmpty(),503,"CONFIG_MISMATCH"); + AuthFailure.require(account!=null&&!account.trim().isEmpty()&&account.length()<=256&&password!=null&&!password.isEmpty()&&password.length()<=1024,401,"CREDENTIALS_INVALID"); + int uid=accounts.verifyPassword(account,password); + return tx.execute(status->{ + lockUser(uid);ensureEpoch(env,uid);SessionAuthenticator.Snapshot u=accounts.snapshot(uid); + AuthFailure.require(u.active&&u.schoolId!=null&&(u.schoolId>0||(u.schoolId==0&&u.superAdmin))&&u.roleId!=null,403,"IDENTITY_DENIED"); + Map epoch=db.queryForMap("SELECT user_epoch,identity_version FROM mob_auth_epoch WHERE env=? AND user_id=?",env,uid); + long generation=db.queryForObject("SELECT generation FROM mob_auth_generation WHERE env=? AND ready=TRUE",Long.class,env); + return new SessionAuthenticator.Snapshot(uid,u.schoolId,u.roleId,true,u.superAdmin,true,number(epoch,"user_epoch"),number(epoch,"identity_version"),0,generation,u.account,u.name); + }); + } + public SessionAuthenticator.Snapshot resolveCas(String principal) { + CallerAppContext c=caller("mobile.cas.resolve"); + AuthFailure.require(principal!=null&&!principal.trim().isEmpty()&&principal.length()<=256,400,"CAS_PRINCIPAL_INVALID"); + return tx.execute(status->{ + int uid=accounts.casAccount(principal);AuthFailure.require(uid>0,403,"CAS_USER_UNMAPPED"); + lockUser(uid);ensureEpoch(c.env,uid);SessionAuthenticator.Snapshot u=accounts.snapshot(uid); + AuthFailure.require(u.active&&u.schoolId!=null&&(u.schoolId>0||(u.schoolId==0&&u.superAdmin))&&u.roleId!=null,403,"IDENTITY_DENIED"); + Map epoch=db.queryForMap("SELECT user_epoch,identity_version FROM mob_auth_epoch WHERE env=? AND user_id=?",c.env,uid); + long generation=db.queryForObject("SELECT generation FROM mob_auth_generation WHERE env=? AND ready=TRUE",Long.class,c.env); + return new SessionAuthenticator.Snapshot(uid,u.schoolId,u.roleId,true,u.superAdmin,true,number(epoch,"user_epoch"),number(epoch,"identity_version"),0,generation,u.account,u.name); + }); + } + /** The signed caller attests a LIVE portal session. The browser never supplies this envelope. */ + private SessionAuthenticator.Snapshot requirePortalSession(SessionAuthenticator.Session s) { + CallerAppContext c=caller("mobile.identity.validate"); + AuthFailure.require(s!=null&&s.parentHash==null&&c.appId.equals(s.audience),403,"PORTAL_SESSION_REQUIRED"); + SessionAuthenticator.Snapshot a=validate(s); + AuthFailure.require(s.absoluteExpiresAt>clock.instant().getEpochSecond()&&a.active&&a.bindingActive&&a.roleId!=null + &&a.schoolId!=null&&(a.schoolId>0||(a.schoolId==0&&a.superAdmin))&&a.userEpoch==s.userEpoch + &&a.bindingVersion==s.bindingVersion&&a.generation==s.generation&&a.identityVersion>=s.identityVersion,401,"CONTEXT_STALE"); + return a; + } + public Object readPortalIdentity(SessionAuthenticator.Session session,boolean available) { + caller("mobile.identity.read");SessionAuthenticator.Snapshot a=requirePortalSession(session); + if(available)return accounts.available(a.userId); + return accounts.available(a.userId).stream().filter(r->Objects.equals(r.getRoleId(),a.roleId)&&Objects.equals(r.getSchId(),a.schoolId)) + .findFirst().orElseThrow(()->new AuthFailure(403,"IDENTITY_DENIED")); + } + public Map switchPortalIdentity(SessionAuthenticator.Session session,int role,int school,long expected,String op) { + CallerAppContext c=caller("mobile.identity.switch"); + requirePortalSession(session); + String hash=Crypto.hash("PORTAL_SWITCH\n"+session.id+"\n"+session.userId+"\n"+role+"\n"+school+"\n"+expected); + return operation(c,op,hash,()->{ + lockUser(session.userId);requirePortalSession(session); + long current=db.queryForObject("SELECT identity_version FROM mob_auth_epoch WHERE env=? AND user_id=?",Long.class,c.env,session.userId); + AuthFailure.require(current==expected,409,"CONTEXT_STALE"); + AuthFailure.require(school>0||(school==0&&accounts.snapshot(session.userId).superAdmin),403,"SCHOOL_CONTEXT_REQUIRED"); + AuthFailure.require(accounts.available(session.userId).stream().anyMatch(r->Objects.equals(r.getRoleId(),role)&&Objects.equals(r.getSchId(),school)),403,"IDENTITY_DENIED"); + accounts.switchIdentity(session.userId,role,school); + long updated=db.queryForObject("SELECT identity_version FROM mob_auth_epoch WHERE env=? AND user_id=?",Long.class,c.env,session.userId); + if(updated==current){db.update("UPDATE mob_auth_epoch SET identity_version=identity_version+1 WHERE env=? AND user_id=?",c.env,session.userId);updated++;} + AuthFailure.require(updated>current,409,"CONTEXT_STALE"); + Map result=result(session.userId);result.put("identityVersion",updated);result.put("roleId",role);result.put("schoolId",school);result.put("previousIdentityVersion",current);result.put("sessionRef",Crypto.hash(session.id));result.put("committedAt",clock.instant().getEpochSecond());return result; + }); + } + public Map queryBindings(int actor,int offset,int size){ + CallerAppContext c=caller("mobile.binding.read");SessionAuthenticator.Snapshot admin=accounts.snapshot(actor); + AuthFailure.require(admin.active&&admin.superAdmin,403,"ADMIN_REQUIRED");AuthFailure.require(offset>=0&&size>0&&size<=100,400,"PAGE_INVALID"); + List> rows=db.queryForList("SELECT user_id,provider_scope,version FROM mob_auth_subject WHERE env=? AND user_id IS NOT NULL ORDER BY user_id,provider_scope LIMIT ? OFFSET ?",c.env,size,offset); + List> masked=new ArrayList<>();for(Map row:rows){Map item=new LinkedHashMap<>();item.put("userRef","u***"+(number(row,"user_id")%1000));item.put("providerScope",str(row,"provider_scope"));item.put("bindingVersion",number(row,"version"));masked.add(item);} + Map result=new LinkedHashMap<>();result.put("list",masked);result.put("count",db.queryForObject("SELECT COUNT(*) FROM mob_auth_subject WHERE env=? AND user_id IS NOT NULL",Long.class,c.env));result.put("authority","mob_auth_subject");result.put("legacyMigrationVerified",false);return result; + } + private Map operation(CallerAppContext c,String op,String hash,Supplier> work) { + AuthFailure.require(op!=null&&op.matches("[A-Za-z0-9_-]{22,128}"),400,"IDEMPOTENCY_KEY_REQUIRED"); + try { return tx.execute(status->{ + // Shared database lock, not a JVM lock. Serializes this caller's idempotency namespace across nodes. + AuthFailure.require(!db.queryForList("SELECT app_id FROM mob_auth_caller WHERE env=? AND app_id=? FOR UPDATE",c.env,c.appId).isEmpty(),403,"CALLER_CAPABILITY_DENIED"); + Map saved=existing(c,op,hash);if(saved!=null)return saved; + Map value=work.get();String encoded; + try{encoded=json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);} + db.update("INSERT INTO mob_auth_operation(env,caller_id,operation_id,request_hash,result_json) VALUES(?,?,?,?,?)",c.env,c.appId,op,hash,encoded);return value; + }); } catch(DataIntegrityViolationException e) {throw new AuthFailure(409,"BINDING_CONFLICT");} + } + private Map existing(CallerAppContext c,String op,String hash) { + List> rows=db.queryForList("SELECT request_hash,result_json FROM mob_auth_operation WHERE env=? AND caller_id=? AND operation_id=?",c.env,c.appId,op); + if(rows.isEmpty())return null;AuthFailure.require(Crypto.equal(hash,str(rows.get(0),"request_hash")),409,"IDEMPOTENCY_CONFLICT"); + try{return json.readValue(str(rows.get(0),"result_json"),LinkedHashMap.class);}catch(Exception e){throw new AuthFailure(503,"LEDGER_UNAVAILABLE");} + } + private String issue(CallerAppContext c,String action,String flow,Integer user,String providerScope,String subject,long version) { + AuthFailure.require(flow!=null&&flow.length()<=128,400,"FLOW_INVALID");String raw=Crypto.random(); + db.update("INSERT INTO mob_auth_proof(handle_hash,env,caller_id,action,flow_id,user_id,provider_scope,provider_subject_hash,subject_version,expires_at,user_epoch,consumed) VALUES(?,?,?,?,?,?,?,?,?,?,?,FALSE)", + Crypto.hash(raw),c.env,c.appId,action,flow,user,providerScope,subject,version,clock.instant().getEpochSecond()+120,user==null?0:epoch(c.env,user));return raw; + } + private Map proof(CallerAppContext c,String raw,String action,boolean consume) { + AuthFailure.require(raw!=null,401,"PROOF_INVALID");List> rows=db.queryForList("SELECT * FROM mob_auth_proof WHERE handle_hash=?",Crypto.hash(raw)); + AuthFailure.require(rows.size()==1,401,"PROOF_INVALID");Map p=rows.get(0); + AuthFailure.require(c.env.equals(str(p,"env"))&&c.appId.equals(str(p,"caller_id"))&&action.equals(str(p,"action")) + && !Boolean.TRUE.equals(p.get("consumed"))&&!Boolean.TRUE.equals(p.get("CONSUMED"))&&number(p,"expires_at")>clock.instant().getEpochSecond(),401,"PROOF_INVALID"); + if(get(p,"user_id")!=null){int uid=(int)number(p,"user_id");AuthFailure.require(accounts.snapshot(uid).active&&epoch(c.env,uid)==number(p,"user_epoch"),401,"PROOF_INVALID");} + if(consume) consume(c,raw,action);return p; + } + private void consume(CallerAppContext c,String raw,String action) { + int n=db.update("UPDATE mob_auth_proof SET consumed=TRUE WHERE handle_hash=? AND env=? AND caller_id=? AND action=? AND consumed=FALSE AND expires_at>?", + Crypto.hash(raw),c.env,c.appId,action,clock.instant().getEpochSecond());AuthFailure.require(n==1,401,"PROOF_INVALID"); + } + private long subjectVersion(String env,String providerScope,String subject) { + List rows=db.queryForList("SELECT version FROM mob_auth_subject WHERE env=? AND provider_scope=? AND provider_subject_hash=?",Long.class,env,providerScope,subject);return rows.isEmpty()?0:rows.get(0); + } + private int boundUser(String env,String providerScope,String subject) { + List rows=db.queryForList("SELECT user_id FROM mob_auth_subject WHERE env=? AND provider_scope=? AND provider_subject_hash=?",Integer.class,env,providerScope,subject);return rows.isEmpty()||rows.get(0)==null?0:rows.get(0); + } + private void lockUser(int uid) {AuthFailure.require(!db.queryForList("SELECT id FROM sys_user WHERE id=? FOR UPDATE",uid).isEmpty(),401,"USER_REVOKED");} + private void ensureEpoch(String env,int uid) { if(db.queryForObject("SELECT COUNT(*) FROM mob_auth_epoch WHERE env=? AND user_id=?",Integer.class,env,uid)==0) + db.update("INSERT INTO mob_auth_epoch(env,user_id,user_epoch,identity_version) VALUES(?,?,1,1)",env,uid); } + private long epoch(String env,int uid){List rows=db.queryForList("SELECT user_epoch FROM mob_auth_epoch WHERE env=? AND user_id=?",Long.class,env,uid);return rows.isEmpty()?1:rows.get(0);} + private static void requireSameFlow(Map a,Map b) { + AuthFailure.require(str(a,"flow_id").equals(str(b,"flow_id"))&&Objects.equals(a.get("provider_scope"),b.get("provider_scope")) + &&Objects.equals(a.get("provider_subject_hash"),b.get("provider_subject_hash")),401,"PROOF_INVALID"); + } + private static Map result(int uid) {Map r=new LinkedHashMap<>();r.put("userId",uid);r.put("status","COMMITTED");return r;} + private static Object get(Map p,String k) {return p.containsKey(k)?p.get(k):p.get(k.toUpperCase(Locale.ROOT));} + private static String str(Map p,String k) {Object v=get(p,k);return v==null?null:String.valueOf(v);} + private static long number(Map p,String k) {return ((Number)get(p,k)).longValue();} +} diff --git a/src/main/java/com/yunhai/userbase/mobile/MobileProofController.java b/src/main/java/com/yunhai/userbase/mobile/MobileProofController.java new file mode 100644 index 0000000..f3c8006 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/mobile/MobileProofController.java @@ -0,0 +1,27 @@ +package com.yunhai.userbase.mobile; +import com.yunhai.platform.mobile.*; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.web.bind.annotation.*; +import java.util.*; +@RestController @RequestMapping("/oapi/mobile") +public class MobileProofController { + private final ObjectProvider provider; + public MobileProofController(ObjectProvider provider) { this.provider=provider; } + private MobileIdentityService service(){MobileIdentityService s=provider.getIfAvailable();AuthFailure.require(s!=null,503,"AUTHORITY_UNAVAILABLE");return s;} + public static class Channel {public String flowId,subjectHash,action;public ProviderScope scope;public long subjectVersion;} + public static class User {public String channelProof,actionProof,account,password;} + public static class Action {public int userId;public String flowId,subjectHash,action;public ProviderScope scope;public long subjectVersion;} + public static class Operation {public String operationId,requestHash;} + public static class PortalIdentity {public SessionAuthenticator.Session session;public boolean available;public int roleId,schoolId;public long expectedIdentityVersion;public String operationId;} + @PostMapping("/identity/read") public Object readPortal(@RequestBody PortalIdentity b){return service().readPortalIdentity(b.session,b.available);} + @PostMapping("/identity/switch") public Object switchPortal(@RequestBody PortalIdentity b){return service().switchPortalIdentity(b.session,b.roleId,b.schoolId,b.expectedIdentityVersion,b.operationId);} + public static class Cas {public String issuer,principal;} + public static class BindingQuery {public int actor,offset,size=20;} + @PostMapping("/bindings/query") public Object bindings(@RequestBody BindingQuery b){return service().queryBindings(b.actor,b.offset,b.size);} + @PostMapping("/cas/resolve") public void cas(@RequestBody Cas b){throw new AuthFailure(410,"CAS_RETIRED");} + @PostMapping("/identity/validate") public SessionAuthenticator.Snapshot validate(@RequestBody SessionAuthenticator.Session b){return service().validate(b);} + @PostMapping("/proofs/channel") public Map channel(@RequestBody Channel b){AuthFailure.require(b.scope!=null,400,"PROVIDER_SCOPE_INVALID");return Collections.singletonMap("proofHandle",service().channelProof(b.flowId,b.scope.key(),b.subjectHash,b.subjectVersion,b.action));} + @PostMapping("/proofs/user") public Map user(@RequestBody User b){return Collections.singletonMap("proofHandle",b.actionProof!=null?service().reauth(b.actionProof,b.account,b.password):service().passwordProof(b.channelProof,b.account,b.password));} + @PostMapping("/proofs/action") public Map action(@RequestBody Action b){AuthFailure.require(b.scope!=null,400,"PROVIDER_SCOPE_INVALID");return Collections.singletonMap("proofHandle",service().actionProof(b.userId,b.flowId,b.scope.key(),b.subjectHash,b.subjectVersion,b.action));} + @PostMapping("/operations/result") public Map result(@RequestBody Operation b){return service().recover(b.operationId,b.requestHash);} +} diff --git a/src/main/java/com/yunhai/userbase/mobile/MobileSecurityAdvice.java b/src/main/java/com/yunhai/userbase/mobile/MobileSecurityAdvice.java new file mode 100644 index 0000000..4d03a59 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/mobile/MobileSecurityAdvice.java @@ -0,0 +1,18 @@ +package com.yunhai.userbase.mobile; +import com.yunhai.platform.mobile.AuthFailure; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import java.util.*; +@RestControllerAdvice @Order(Ordered.HIGHEST_PRECEDENCE) +public class MobileSecurityAdvice { + @ExceptionHandler(org.springframework.dao.DataAccessException.class) + public ResponseEntity> unavailable(org.springframework.dao.DataAccessException e) { + return failure(new AuthFailure(503,"AUTHORITY_UNAVAILABLE")); + } + @ExceptionHandler(AuthFailure.class) public ResponseEntity> failure(AuthFailure e) { + Map dto=new LinkedHashMap<>();dto.put("code",e.code);dto.put("message",e.code);dto.put("data",null); + return ResponseEntity.status(e.status).header("Cache-Control","no-store").body(dto); + } +} diff --git a/src/main/java/com/yunhai/userbase/mobile/MobileVersionListener.java b/src/main/java/com/yunhai/userbase/mobile/MobileVersionListener.java new file mode 100644 index 0000000..425f64c --- /dev/null +++ b/src/main/java/com/yunhai/userbase/mobile/MobileVersionListener.java @@ -0,0 +1,19 @@ +package com.yunhai.userbase.mobile; +import com.yunhai.platform.mobile.AuthFailure; +import com.yunhai.userbase.event.*; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.event.EventListener; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** Synchronous events join the writer's transaction; never rely on AFTER_COMMIT invalidation. */ +@Component @ConditionalOnProperty(name="yh.mobile.identity-store-enabled",havingValue="true") +public class MobileVersionListener { + private final JdbcTemplate db;public MobileVersionListener(JdbcTemplate db){this.db=db;} + private void transactional(){if(!TransactionSynchronizationManager.isActualTransactionActive()){ + db.update("UPDATE mob_auth_generation SET ready=FALSE");throw new AuthFailure(503,"VERSION_WRITE_OUTSIDE_TRANSACTION");}} + @EventListener public void user(UserChangedEvent event){transactional();for(Integer uid:event.getUserIds()) + db.update(event.getSource() instanceof com.yunhai.userbase.service.impl.IdentityService ? "UPDATE mob_auth_epoch SET identity_version=identity_version+1 WHERE user_id=?" : "UPDATE mob_auth_epoch SET user_epoch=user_epoch+1 WHERE user_id=?",uid);} + @EventListener public void group(GroupChangedEvent event){transactional();db.update("UPDATE mob_auth_epoch SET user_epoch=user_epoch+1");} +} diff --git a/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoVo.java b/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoVo.java index 23492e6..52c3d53 100644 --- a/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoVo.java +++ b/src/main/java/com/yunhai/userbase/model/application/ApplicationInfoVo.java @@ -12,6 +12,9 @@ import java.util.List; */ @Data public class ApplicationInfoVo { + /** Stable, non-secret application incarnation used for source isolation. */ + private String sourcePrincipalId; + /** * 主键ID。
*/ diff --git a/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java b/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java index b78db38..2e73050 100644 --- a/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java +++ b/src/main/java/com/yunhai/userbase/repository/ApplicationInfoRepository.java @@ -33,14 +33,9 @@ public interface ApplicationInfoRepository extends JpaRepository - * 用于新增应用时生成从 100000 起自增的整型 appKey。
- * - * @return 当前最大 appKey,无记录时返回 null - */ - @Query("SELECT MAX(a.appKey) FROM ApplicationInfo a") - Integer findMaxAppKey(); + // findMaxAppKey 已删(2026-10-26 P2-6):凭据 key 一律由 application_key_reservation 的 + // insert-only 永久保留行分配(ApplicationInfoService.allocateAppKey),存活表 MAX 不得 + // 作为分配依据——已删除应用可能占用更高 key,max(存活)+1 会复用注销凭据。 /** * 按整型 appKey 查单个应用(验签兜底端点直连读取用)。
diff --git a/src/main/java/com/yunhai/userbase/repository/ApplicationKeyReservationRepository.java b/src/main/java/com/yunhai/userbase/repository/ApplicationKeyReservationRepository.java new file mode 100644 index 0000000..b8bdba7 --- /dev/null +++ b/src/main/java/com/yunhai/userbase/repository/ApplicationKeyReservationRepository.java @@ -0,0 +1,4 @@ +package com.yunhai.userbase.repository; +import com.yunhai.userbase.entity.ApplicationKeyReservation; +import org.springframework.data.jpa.repository.JpaRepository; +public interface ApplicationKeyReservationRepository extends JpaRepository { } diff --git a/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java b/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java index 633e3b3..1eb436f 100644 --- a/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java +++ b/src/main/java/com/yunhai/userbase/service/IApplicationInfoService.java @@ -68,7 +68,7 @@ public interface IApplicationInfoService { /** * 新增应用信息。
- * 由入参构造实体并落库;appKey 为整型,从 100000 起按数据库最大值自增,appSecret 为 32 位 UUID。
+ * 由入参构造实体并落库;appKey 由 application_key_reservation 永久保留行分配(受历史身份审计门禁约束,见 allocateAppKey),appSecret 为 32 位 UUID。
* * @param request 应用新增入参 */ diff --git a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java index 0784a4c..9cf7987 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/ApplicationInfoService.java @@ -2,6 +2,7 @@ package com.yunhai.userbase.service.impl; import com.yunhai.userbase.entity.AppModule; import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.entity.ApplicationKeyReservation; import com.yunhai.userbase.entity.SysAppRole; import com.yunhai.userbase.constant.AppNature; import com.yunhai.userbase.repository.AppModuleRepository; @@ -51,6 +52,31 @@ public class ApplicationInfoService implements IApplicationInfoService { @Autowired private ApplicationInfoRepository applicationInfoRepository; + @Autowired + private com.yunhai.userbase.repository.ApplicationKeyReservationRepository applicationKeys; + + /** + * 发放应用凭据 key(P1-3 机械门禁,fail-closed):
+ * ① 哨兵行({@link ApplicationKeyReservation#GATE_CLOSED_SENTINEL})在=历史身份审计未完成, + * 拒绝发放——门禁解除只能走迁移脚本的 open_application_key_gate 存储过程(先回填 tombstone、 + * 校验审计上界、抬升 AUTO_INCREMENT 地板,最后才摘哨兵);② 保留表未迁移/AUTO_INCREMENT + * 种子丢失时 key 落在保留段之下,同样拒绝。任何情况下不以存活表 MAX 推断历史上界。 + */ + private Integer allocateAppKey() { + boolean gateClosed; + try { + gateClosed = applicationKeys.existsById(ApplicationKeyReservation.GATE_CLOSED_SENTINEL); + } catch (Exception gateStateUnreadable) { + throw new DException("应用凭据门禁状态不可读,fail-closed 拒绝发放新凭据"); + } + if (gateClosed) { + throw new DException("历史应用身份审计未完成(key 门禁未解除),拒绝发放新凭据"); + } + Integer key = applicationKeys.saveAndFlush(new ApplicationKeyReservation()).getAppKey(); + if (key == null || key < 100000) throw new DException("应用凭据保留表尚未迁移,拒绝发放可复用凭据"); + return key; + } + @Autowired private AppModuleRepository appModuleRepository; @@ -144,10 +170,16 @@ public class ApplicationInfoService implements IApplicationInfoService { * 通用保存:方法返回后发布变更事件,缓存失效由 AFTER_COMMIT 监听器统一执行。 */ @Override + @Transactional public ApplicationInfo save(ApplicationInfo info) { if (info.getId() != null) { + if (!applicationInfoRepository.existsById(info.getId())) { + throw new DException("应用不存在;新增必须使用服务端生成的主体和凭据"); + } requireUnchangedAppCode(applicationInfoRepository.findPersistedAppCodeById(info.getId()), info.getAppCode()); + } else { + info.setAppKey(allocateAppKey()); } ApplicationInfo saved = applicationInfoRepository.save(info); eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved)); @@ -182,10 +214,8 @@ public class ApplicationInfoService implements IApplicationInfoService { ApplicationInfo entity = new ApplicationInfo(); BeanUtils.copyProperties(request, entity); entity.setId(null); - // appKey:整型,从 100000 起,取数据库现有最大 appKey + 1 - Integer maxAppKey = applicationInfoRepository.findMaxAppKey(); - Integer appKey = (maxAppKey == null) ? 100000 : maxAppKey + 1; - entity.setAppKey(appKey); + // Permanent reservations serialize allocation and survive deletion/restart, unlike max(live keys)+1. + entity.setAppKey(allocateAppKey()); // appSecret:32 位 UUID(去除连字符) entity.setAppSecret(UUID.randomUUID().toString().replace("-", "")); ApplicationInfo saved = applicationInfoRepository.save(entity); diff --git a/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java b/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java index bde825f..9488306 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/IdentityService.java @@ -136,10 +136,14 @@ public class IdentityService { return null; } + /** Authentication reads the authoritative current row, bypassing this bean's presentation cache. */ + public BoaRoleInfo getCurrentForAuthentication(Integer userId) { return getCurrent(userId); } + /** * 切换身份:校验 (userId, roleId, schoolId) 在 sys_user_role_rel 正常态中存在, * 再 UPSERT sys_user_current_role(按 userId 唯一)。 */ + @org.springframework.transaction.annotation.Transactional public BoaRoleInfo switchIdentity(Integer userId, Integer roleId, Integer schoolId) { // 校验目标身份确属该用户且正常态,防越权切换 SysUserRoleRel rel = sysUserRoleRelRepository diff --git a/src/main/resources/sql/20261026_application_source_principal_draft.sql b/src/main/resources/sql/20261026_application_source_principal_draft.sql new file mode 100644 index 0000000..75a43e5 --- /dev/null +++ b/src/main/resources/sql/20261026_application_source_principal_draft.sql @@ -0,0 +1,93 @@ +-- REVIEW DRAFT. No production execution is authorized. MySQL 5.6 DDL auto-commits. +-- 2026-10-26 P1-3 rework: the historical-identity gate below is MECHANICAL, not documentary. +-- +-- Gate semantics (fail-closed, MySQL 5.6/8 compatible): +-- 1. application_key_reservation is created with a GATE-CLOSED sentinel row (app_key = -1). +-- While that row exists, ApplicationInfoService.allocateAppKey() REFUSES every new +-- credential (application-level check, not operator discipline). +-- 2. The script is idempotent and re-runnable in any direction: +-- - sentinel is seeded ONLY while the table is empty => re-running after the gate was +-- opened can never re-close it (an opened gate always has reservation rows); +-- - live-key backfill uses INSERT IGNORE => no duplicates, safe to re-run; +-- - source_principal_id backfill re-runs harmlessly (NULL-guarded). +-- 3. Opening the gate is a stored procedure, not a bare DELETE: it validates the +-- auditor-supplied historical high-water mark against the database (live keys and +-- existing reservations must not exceed it; every live key must already be reserved), +-- raises AUTO_INCREMENT above the mark, and only then removes the sentinel. +-- Any inconsistency aborts with SIGNAL and the gate stays closed. +-- 4. Why -1 and not 0: with default sql_mode, inserting literal 0 into an AUTO_INCREMENT +-- column stores the NEXT generated value instead of 0 (NO_AUTO_VALUE_ON_ZERO off). +-- -1 is stored literally, sorts below the reserved band, and the existing +-- `key < 100000` allocation fuse would reject it even if it were ever returned. +-- 5. Order matters: run this script BEFORE deploying server code that reads the +-- reservation table (ddl-auto=update would otherwise create an unseeded, ungated table; +-- the key<100000 fuse still rejects that case, but the gate state is only established here). + +-- ---------------------------------------------------------------- key reservations ---- +CREATE TABLE IF NOT EXISTS application_key_reservation ( + app_key int NOT NULL AUTO_INCREMENT PRIMARY KEY +) ENGINE=InnoDB AUTO_INCREMENT=100000; +-- Permanent key reservations prevent a stale credential index from naming a new incarnation. + +-- GATE-CLOSED sentinel: MUST be seeded BEFORE the live-key backfill below — the empty-table +-- guard is the idempotency anchor, and a backfill-filled table would otherwise skip it +-- (defect caught by the 2026-10-26 MySQL 8 verification matrix, first revision). +INSERT INTO application_key_reservation(app_key) +SELECT -1 FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM application_key_reservation) + AND NOT EXISTS (SELECT 1 FROM application_key_reservation WHERE app_key = -1); + +-- Seed ALL historically issued appKeys (including deleted apps) from the audited registry/backups +-- BEFORE calling open_application_key_gate. The INSERT below preserves current keys only. +INSERT IGNORE INTO application_key_reservation(app_key) +SELECT app_key FROM application_info WHERE app_key IS NOT NULL; + +-- Gate-open procedure: see header §3. audited_max = highest appKey ever issued, live or +-- deleted, per the completed historical audit (registry/backup derived — NEVER guessed +-- from the live table's MAX: deleted apps may sit above it). +DROP PROCEDURE IF EXISTS open_application_key_gate; +DELIMITER $$ +CREATE PROCEDURE open_application_key_gate(IN audited_max INT) +BEGIN + DECLARE live_unreserved INT; + DECLARE live_max INT; + DECLARE reserved_max INT; + DECLARE next_floor INT; + + SELECT COUNT(*) INTO live_unreserved + FROM application_info a LEFT JOIN application_key_reservation r ON a.app_key = r.app_key + WHERE a.app_key IS NOT NULL AND r.app_key IS NULL; + IF live_unreserved > 0 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = + 'gate refused: live appKey(s) without a reservation row - backfill tombstones first'; + END IF; + + SELECT COALESCE(MAX(app_key), 0) INTO live_max FROM application_info; + SELECT COALESCE(MAX(app_key), 0) INTO reserved_max FROM application_key_reservation WHERE app_key > 0; + IF audited_max IS NULL OR audited_max < live_max OR audited_max < reserved_max THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = + 'gate refused: audited high-water mark is below a known live/reserved key'; + END IF; + + IF EXISTS (SELECT 1 FROM application_key_reservation WHERE app_key = -1) THEN + SET next_floor = audited_max + 1; + IF next_floor < reserved_max + 1 THEN + SET next_floor = reserved_max + 1; + END IF; + DELETE FROM application_key_reservation WHERE app_key = -1; + SET @gate_floor_sql = CONCAT( + 'ALTER TABLE application_key_reservation AUTO_INCREMENT = ', next_floor); + PREPARE gate_floor_stmt FROM @gate_floor_sql; + EXECUTE gate_floor_stmt; + DEALLOCATE PREPARE gate_floor_stmt; + END IF; + -- re-call after open: no-op success (idempotent). +END$$ +DELIMITER ; +-- Historical rows need an explicit, recorded one-time incarnation assignment, not reconstruction from numeric IDs. +-- UUID() must execute once per row. Review replication/binlog policy before authorizing this backfill. +UPDATE application_info SET source_principal_id=LOWER(UUID()) WHERE source_principal_id IS NULL; +CREATE UNIQUE INDEX uk_application_source_principal ON application_info(source_principal_id); +-- Deploy the server-generated @PrePersist UUID code; clear application caches through normal post-commit invalidation. +-- Verify every active app has a valid distinct UUID and by-key exposes it. Only then re-grant FL licenses. +-- UUID must never be regenerated on secret reset, edit, or deployment. A delete/recreate must get a new UUID. diff --git a/src/main/resources/sql/MOB_AUTH_03_schema_DRAFT.sql b/src/main/resources/sql/MOB_AUTH_03_schema_DRAFT.sql new file mode 100644 index 0000000..ebec621 --- /dev/null +++ b/src/main/resources/sql/MOB_AUTH_03_schema_DRAFT.sql @@ -0,0 +1,39 @@ +-- DRAFT ONLY. No automatic migration registration. Must review/authorize before applying. +-- Empty new scopes remain quarantined; never infer env/provider_scope for old channel rows. +CREATE TABLE mob_auth_epoch ( + env VARCHAR(32) NOT NULL, user_id INT NOT NULL, user_epoch BIGINT NOT NULL DEFAULT 1, + identity_version BIGINT NOT NULL DEFAULT 1, PRIMARY KEY(env,user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +CREATE TABLE mob_auth_subject ( + env VARCHAR(32) NOT NULL, provider_scope VARCHAR(512) NOT NULL, provider_subject_hash VARCHAR(64) NOT NULL, + user_id INT NULL, version BIGINT NOT NULL DEFAULT 1, + PRIMARY KEY(env,provider_scope,provider_subject_hash), UNIQUE(env,provider_scope,user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +CREATE TABLE mob_auth_union_owner ( + env VARCHAR(32) NOT NULL, platform_ref VARCHAR(128) NOT NULL, union_hash VARCHAR(64) NOT NULL, + user_id INT NOT NULL, PRIMARY KEY(env,platform_ref,union_hash) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +CREATE TABLE mob_auth_proof ( + handle_hash VARCHAR(64) PRIMARY KEY, env VARCHAR(32) NOT NULL, caller_id VARCHAR(64) NOT NULL, + action VARCHAR(64) NOT NULL, flow_id VARCHAR(128) NOT NULL, user_id INT NULL, + provider_scope VARCHAR(512) NULL, provider_subject_hash VARCHAR(64) NULL, subject_version BIGINT NOT NULL, + expires_at BIGINT NOT NULL, user_epoch BIGINT NOT NULL, consumed BOOLEAN NOT NULL DEFAULT FALSE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +CREATE TABLE mob_auth_operation ( + env VARCHAR(32) NOT NULL, caller_id VARCHAR(64) NOT NULL, operation_id VARCHAR(128) NOT NULL, + request_hash VARCHAR(64) NOT NULL, result_json VARCHAR(4096) NOT NULL, + PRIMARY KEY(env,caller_id,operation_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +-- Registry exports are administered by UserBase. No keys/capabilities are seeded here. +CREATE TABLE mob_auth_caller_scope ( + env VARCHAR(32) NOT NULL, app_id VARCHAR(64) NOT NULL, capability VARCHAR(128) NOT NULL, + provider_scope VARCHAR(512) NOT NULL, PRIMARY KEY(env,app_id,capability,provider_scope) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +CREATE TABLE mob_auth_generation (env VARCHAR(32) PRIMARY KEY, generation BIGINT NOT NULL, ready BOOLEAN NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; +CREATE TABLE mob_auth_caller (env VARCHAR(32) NOT NULL, app_id VARCHAR(64) NOT NULL, PRIMARY KEY(env,app_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; + +-- READ-ONLY historical diagnostic, no inferred backfill: +SELECT channel, COUNT(*) AS unscoped_count FROM sys_user_channel_rel GROUP BY channel; +SELECT channel, openid, COUNT(*) AS duplicates FROM sys_user_channel_rel GROUP BY channel,openid HAVING COUNT(*)>1; +SELECT unionid, COUNT(DISTINCT user_id) AS owners FROM sys_user_channel_rel + WHERE unionid IS NOT NULL GROUP BY unionid HAVING COUNT(DISTINCT user_id)>1; diff --git a/src/main/resources/sql/application_bootstrap.sql b/src/main/resources/sql/application_bootstrap.sql index c85223c..febd433 100644 --- a/src/main/resources/sql/application_bootstrap.sql +++ b/src/main/resources/sql/application_bootstrap.sql @@ -12,8 +12,8 @@ -- 用于对外发起 yh-app-auth 签名的应用签名请求;因此固定占用 appKey=100000。 -- 3. appSecret 每次执行脚本若不存在时会重新生成一个 UUID(32 位十六进制); -- 已存在记录不会被修改,生产上如需重置请手动 UPDATE 或在应用管理界面点「重设密钥」。 --- 4. 业务应用新增时 ApplicationInfoService 会按 MAX(app_key)+1 分配: --- 因此本脚本写入 100000 后,下一条业务应用 appKey=100001,与预期一致。 +-- 4. 业务应用新增时 ApplicationInfoService 由 application_key_reservation 永久保留行分配 +-- (2026-10-26 起不再按 MAX(app_key)+1;本脚本写入 100000 后下一条业务应用自 100001 起,与预期一致)。 -- 5. AUTO_INCREMENT=100001 仅设置 application_info 表的主键 id(非 appKey), -- 使后续新建行的主键 id 也从 100001 起,与 appKey 段对齐,便于排查。 -- ============================================================ diff --git a/src/test/java/com/yunhai/userbase/controller/oapi/AppControllerActivatedTest.java b/src/test/java/com/yunhai/userbase/controller/oapi/AppControllerActivatedTest.java new file mode 100644 index 0000000..b88ad58 --- /dev/null +++ b/src/test/java/com/yunhai/userbase/controller/oapi/AppControllerActivatedTest.java @@ -0,0 +1,90 @@ +package com.yunhai.userbase.controller.oapi; + +import com.yunhai.platform.utils.exception.DException; +import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.model.application.AppActivatedVo; +import com.yunhai.userbase.service.IApplicationInfoService; +import com.yunhai.userbase.service.IGroupAuthzService; +import com.yunhai.userbase.service.ISysGroupService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * /oapi/app/activated 单测(2026-10-25 T01 应用×学校单点开通判定):
+ * 权威事实源=sys_group_app_rel(学校组单点、不沿祖先),与 list-by-school 同口径收敛成 boolean。
+ * 钉四条边界:开通=true;未开通=false;应用不存在(含注销)=false(不做存在性探测面); + * schoolId 非学校锚点=业务错误(assertSchoolId 既有口径)。 + */ +@DisplayName("AppController.activated - 应用×学校单点开通判定(T01)") +class AppControllerActivatedTest { + + @InjectMocks + private AppController controller; + + @Mock + private IGroupAuthzService groupAuthzService; + + @Mock + private IApplicationInfoService applicationInfoService; + + @Mock + private ISysGroupService sysGroupService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void 该校单点已开通_返回true() { + when(applicationInfoService.getById(100011)).thenReturn(new ApplicationInfo()); + when(groupAuthzService.findAuthorizedAppIds(27)).thenReturn(Arrays.asList(100011, 100013)); + + AppActivatedVo vo = controller.activated(100011, 27); + + assertTrue(vo.isActivated()); + assertEquals(100011, vo.getAppId()); + assertEquals(27, vo.getSchoolId()); + verify(sysGroupService).assertSchoolId(27); + } + + @Test + void 开通集合不含该应用_返回false() { + when(applicationInfoService.getById(100011)).thenReturn(new ApplicationInfo()); + when(groupAuthzService.findAuthorizedAppIds(27)).thenReturn(Collections.singletonList(100013)); + + assertFalse(controller.activated(100011, 27).isActivated()); + } + + @Test + void 应用不存在_返回false且不查开通集合() { + when(applicationInfoService.getById(99999)).thenReturn(null); + + assertFalse(controller.activated(99999, 27).isActivated()); + verify(groupAuthzService, never()).findAuthorizedAppIds(anyInt()); + } + + @Test + void 学校锚点非法_按既有口径抛业务错误() { + org.mockito.Mockito.doThrow(new DException("组不存在或非学校组")) + .when(sysGroupService).assertSchoolId(12345); + + assertThrows(DException.class, () -> controller.activated(100011, 12345)); + } +} diff --git a/src/test/java/com/yunhai/userbase/controller/oapi/PasswordVerificationControllerTest.java b/src/test/java/com/yunhai/userbase/controller/oapi/PasswordVerificationControllerTest.java new file mode 100644 index 0000000..21a9783 --- /dev/null +++ b/src/test/java/com/yunhai/userbase/controller/oapi/PasswordVerificationControllerTest.java @@ -0,0 +1,20 @@ +package com.yunhai.userbase.controller.oapi; +import com.yunhai.platform.mobile.*; +import com.yunhai.userbase.mobile.MobileIdentityService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.support.StaticListableBeanFactory; +import org.springframework.test.web.servlet.*; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +class PasswordVerificationControllerTest { + @Test void requiresSignatureAndPortalAllowlistBeforeReadingCredentials()throws Exception{ + MobileIdentityService service=new MobileIdentityService(null,null,null,java.time.Clock.systemUTC());StaticListableBeanFactory beans=new StaticListableBeanFactory();beans.addBean("identity",service); + MockMvc mvc=MockMvcBuilders.standaloneSetup(new PasswordVerificationController(beans.getBeanProvider(MobileIdentityService.class),"dev","portal")).build(); + mvc.perform(post("/oapi/user/verifyPassword").contentType("application/json").content("{\"account\":\"a\",\"password\":\"secret\"}")).andExpect(status().isUnauthorized()); + mvc.perform(post("/oapi/user/verifyPassword").requestAttr("yh.app.auth","notice").contentType("application/json").content("{}")).andExpect(status().isForbidden()); + mvc.perform(post("/oapi/user/verifyPassword").requestAttr("yh.app.auth","portal").contentType("application/json").content("{}")) + .andExpect(status().isOk()).andExpect(header().string("Cache-Control","no-store")).andExpect(jsonPath("$.code").value(40101)); + } +} diff --git a/src/test/java/com/yunhai/userbase/entity/ApplicationSourcePrincipalTest.java b/src/test/java/com/yunhai/userbase/entity/ApplicationSourcePrincipalTest.java new file mode 100644 index 0000000..85af0fe --- /dev/null +++ b/src/test/java/com/yunhai/userbase/entity/ApplicationSourcePrincipalTest.java @@ -0,0 +1,19 @@ +package com.yunhai.userbase.entity; +import org.junit.jupiter.api.Test; +import javax.persistence.Column; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; + +class ApplicationSourcePrincipalTest { + @Test void reusedNumericIdsAndKeysCannotChooseOrInheritTheIncarnation() { + ApplicationInfo first=new ApplicationInfo();first.setId(7);first.setAppKey(100018);first.assignSourcePrincipal(); + ApplicationInfo replacement=new ApplicationInfo();replacement.setId(7);replacement.setAppKey(100018); + replacement.setSourcePrincipalId(first.getSourcePrincipalId());replacement.assignSourcePrincipal(); + assertNotEquals(first.getSourcePrincipalId(),replacement.getSourcePrincipalId()); + assertEquals(replacement.getSourcePrincipalId(),UUID.fromString(replacement.getSourcePrincipalId()).toString()); + } + @Test void identityMappingIsUniqueAndImmutableOnJpaUpdates() throws Exception { + Column column=ApplicationInfo.class.getDeclaredField("sourcePrincipalId").getAnnotation(Column.class); + assertTrue(column.unique());assertFalse(column.updatable()); + } +} diff --git a/src/test/java/com/yunhai/userbase/mobile/IdentityAuthorityReadTest.java b/src/test/java/com/yunhai/userbase/mobile/IdentityAuthorityReadTest.java new file mode 100644 index 0000000..281c4ae --- /dev/null +++ b/src/test/java/com/yunhai/userbase/mobile/IdentityAuthorityReadTest.java @@ -0,0 +1,22 @@ +package com.yunhai.userbase.mobile; +import com.yunhai.userbase.service.impl.IdentityService; +import com.yunhai.userbase.repository.SysUserCurrentRoleRepository; +import com.yunhai.userbase.entity.SysUserCurrentRole; +import org.junit.jupiter.api.Test; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.cache.annotation.AnnotationCacheOperationSource; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.cache.interceptor.CacheInterceptor; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.Optional; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +class IdentityAuthorityReadTest { + @Test void authenticationBypassesPreviouslyCachedCurrentRole(){ + IdentityService target=new IdentityService();SysUserCurrentRoleRepository rows=mock(SysUserCurrentRoleRepository.class);ReflectionTestUtils.setField(target,"sysUserCurrentRoleRepository",rows); + SysUserCurrentRole a=new SysUserCurrentRole();a.setUserId(12);a.setSchoolId(10);a.setSchoolName("fixture-A");a.setRoleId(1); + SysUserCurrentRole b=new SysUserCurrentRole();b.setUserId(12);b.setSchoolId(20);b.setSchoolName("fixture-B");b.setRoleId(7); + when(rows.findByUserId(12)).thenReturn(Optional.of(a));CacheInterceptor cache=new CacheInterceptor();cache.setCacheManager(new ConcurrentMapCacheManager());cache.setCacheOperationSources(new AnnotationCacheOperationSource());cache.afterPropertiesSet();cache.afterSingletonsInstantiated();ProxyFactory proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(cache);IdentityService service=(IdentityService)proxy.getProxy(); + assertEquals(10,service.getCurrent(12).getSchId());when(rows.findByUserId(12)).thenReturn(Optional.of(b));assertEquals(10,service.getCurrent(12).getSchId());assertEquals(20,service.getCurrentForAuthentication(12).getSchId());assertEquals(7,service.getCurrentForAuthentication(12).getRoleId()); + } +} diff --git a/src/test/java/com/yunhai/userbase/mobile/MobileAuthoritySchoolZeroTest.java b/src/test/java/com/yunhai/userbase/mobile/MobileAuthoritySchoolZeroTest.java new file mode 100644 index 0000000..e684242 --- /dev/null +++ b/src/test/java/com/yunhai/userbase/mobile/MobileAuthoritySchoolZeroTest.java @@ -0,0 +1,25 @@ +package com.yunhai.userbase.mobile; +import com.yunhai.platform.mobile.*; +import com.yunhai.platform.utils.model.BoaRoleInfo; +import com.yunhai.userbase.entity.SysUser; +import com.yunhai.userbase.enums.*; +import com.yunhai.userbase.repository.SysUserRepository; +import com.yunhai.userbase.service.IGroupAuthzService; +import com.yunhai.userbase.service.impl.IdentityService; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import java.util.*; +import java.time.*; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; +class MobileAuthoritySchoolZeroTest { + @Test void authoritativeSuperAdminMayUseRegisteredPlatformAudienceAtSchoolZero()throws Exception{ + JdbcTemplate db=mock(JdbcTemplate.class);SysUserRepository users=mock(SysUserRepository.class);IdentityService identities=mock(IdentityService.class);IGroupAuthzService apps=mock(IGroupAuthzService.class); + SysUser user=new SysUser();user.setId(12);user.setType(UserTypeEnum.SUPER_ADMIN);user.setDisabled(StatusEnum.NORMAL);when(users.findById(12)).thenReturn(Optional.of(user));BoaRoleInfo role=new BoaRoleInfo();role.setSchId(0);role.setRoleId(7);when(identities.getCurrentForAuthentication(12)).thenReturn(role);when(identities.listAvailable(12)).thenReturn(Collections.singletonList(role));when(apps.findUserAuthorizedAppIds(12,0)).thenReturn(Collections.singletonList(13)); + when(db.queryForObject("SELECT generation FROM mob_auth_generation WHERE env=? AND ready=TRUE",Long.class,"test")).thenReturn(1L);Map epoch=new HashMap<>();epoch.put("user_epoch",1L);epoch.put("identity_version",1L);when(db.queryForMap("SELECT user_epoch,identity_version FROM mob_auth_epoch WHERE env=? AND user_id=?","test",12)).thenReturn(epoch); + MobileIdentityService service=new MobileIdentityConfiguration().mobileIdentityService(db,mock(org.springframework.transaction.PlatformTransactionManager.class),users,mock(org.springframework.security.crypto.password.PasswordEncoder.class),identities,apps); + java.security.KeyPairGenerator generator=java.security.KeyPairGenerator.getInstance("RSA");generator.initialize(2048);java.security.KeyPair key=generator.generateKeyPair();RequestAssertion requests=new RequestAssertion((e,i,k)->new RequestAssertion.TrustedKey(key.getPublic(),Collections.singleton("UB"),Collections.singleton("mobile.identity.validate")),(k,t)->true,Clock.systemUTC(),"fixture");long now=Instant.now().getEpochSecond();String signed=requests.sign(key.getPrivate(),"k","MP","UB","test",Crypto.random(),now,now+30,"POST","/validate",new byte[0],"mobile.identity.validate",null); + org.springframework.mock.web.MockHttpServletRequest request=new org.springframework.mock.web.MockHttpServletRequest("POST","/validate");request.addHeader(RequestAssertion.HEADER,signed);org.springframework.mock.web.MockHttpServletResponse response=new org.springframework.mock.web.MockHttpServletResponse(); + new StrongRequestFilter(()->requests,r->new StrongRequestFilter.Required("test","UB","mobile.identity.validate","/validate")).doFilter(request,response,(req,res)->{SessionAuthenticator.Session session=new SessionAuthenticator.Session("fixture","test","13","h5","subject",null,12,0,7,1,1,0,1,now,now+300,300);assertEquals(0,service.validate(session).schoolId);user.setType(UserTypeEnum.NORMAL);assertEquals(403,assertThrows(AuthFailure.class,()->service.validate(session)).status);});assertEquals(200,response.getStatus()); + } +} diff --git a/src/test/java/com/yunhai/userbase/mobile/MobileContextVersionTest.java b/src/test/java/com/yunhai/userbase/mobile/MobileContextVersionTest.java new file mode 100644 index 0000000..b581df2 --- /dev/null +++ b/src/test/java/com/yunhai/userbase/mobile/MobileContextVersionTest.java @@ -0,0 +1,13 @@ +package com.yunhai.userbase.mobile; +import com.yunhai.userbase.event.UserChangedEvent; +import com.yunhai.userbase.service.impl.IdentityService; +import org.junit.jupiter.api.*; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import java.util.Collections; +import static org.mockito.Mockito.*; +class MobileContextVersionTest { + @AfterEach void clear(){TransactionSynchronizationManager.setActualTransactionActive(false);} + @Test void identitySwitchAdvancesContextVersionWithoutRevokingUserEpoch(){JdbcTemplate db=mock(JdbcTemplate.class);TransactionSynchronizationManager.setActualTransactionActive(true);new MobileVersionListener(db).user(new UserChangedEvent(mock(IdentityService.class),Collections.singletonList(12),null));verify(db).update("UPDATE mob_auth_epoch SET identity_version=identity_version+1 WHERE user_id=?",12);verifyNoMoreInteractions(db);} + @Test void accountAndEligibilityChangeStillRevokesEpoch(){JdbcTemplate db=mock(JdbcTemplate.class);TransactionSynchronizationManager.setActualTransactionActive(true);new MobileVersionListener(db).user(new UserChangedEvent(new Object(),Collections.singletonList(12),null));verify(db).update("UPDATE mob_auth_epoch SET user_epoch=user_epoch+1 WHERE user_id=?",12);} +} diff --git a/src/test/java/com/yunhai/userbase/mobile/MobileIdentityTransactionTest.java b/src/test/java/com/yunhai/userbase/mobile/MobileIdentityTransactionTest.java new file mode 100644 index 0000000..bee75e6 --- /dev/null +++ b/src/test/java/com/yunhai/userbase/mobile/MobileIdentityTransactionTest.java @@ -0,0 +1,167 @@ +package com.yunhai.userbase.mobile; + +import com.yunhai.platform.mobile.*; +import org.junit.jupiter.api.*; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.mock.web.*; +import org.springframework.transaction.support.TransactionTemplate; +import java.security.*; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import java.io.*; +import static org.junit.jupiter.api.Assertions.*; + +/** Real H2 transactions/row locks; fake account authority, no external DB or credentials. */ +class MobileIdentityTransactionTest { + static KeyPair keys;JdbcTemplate db;MobileIdentityService service; + final AtomicBoolean switchPublishesVersion=new AtomicBoolean(false); + final String providerScope=new ProviderScope("wechat","wxgzh","wx-test",null).key(); + final Set replay=ConcurrentHashMap.newKeySet();final String subject=Crypto.hash("fixture-subject"); + final Set caps=new HashSet<>(Arrays.asList("proof.channel.register","proof.user.issue","channel.bind.password","channel.resolve", + "proof.action.register","channel.unbind.self","identity.switch.self","identity.read.self","mobile.operation.read","mobile.identity.validate","mobile.identity.read","mobile.identity.switch")); + @BeforeAll static void key() throws Exception {KeyPairGenerator g=KeyPairGenerator.getInstance("RSA");g.initialize(2048);keys=g.generateKeyPair();} + @BeforeEach void setup() throws Exception { + DriverManagerDataSource ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE;LOCK_TIMEOUT=10000","sa",""); + db=new JdbcTemplate(ds);db.execute("CREATE TABLE sys_user(id INT PRIMARY KEY)");db.update("INSERT INTO sys_user(id) VALUES(1),(2)"); + String sql=new String(read(getClass().getResourceAsStream("/sql/MOB_AUTH_03_schema_DRAFT.sql")),java.nio.charset.StandardCharsets.UTF_8).split("-- READ-ONLY")[0]; + sql=sql.replace(" ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin",""); + sql=sql.replaceAll("(?m)--[^\\n]*","");for(String statement:sql.split(";"))if(!statement.trim().isEmpty())db.execute(statement); + db.update("INSERT INTO mob_auth_caller(env,app_id) VALUES('dev','M')"); + for(String cap:caps)db.update("INSERT INTO mob_auth_caller_scope(env,app_id,capability,provider_scope) VALUES('dev','M',?,?)",cap,providerScope); + MobileIdentityService.Accounts accounts=new MobileIdentityService.Accounts(){ + public java.util.List available(int uid){ + com.yunhai.platform.utils.model.BoaRoleInfo a=new com.yunhai.platform.utils.model.BoaRoleInfo(),b=new com.yunhai.platform.utils.model.BoaRoleInfo(); + a.setRoleId(1);a.setSchId(10);b.setRoleId(7);b.setSchId(20);return Arrays.asList(a,b); + } + public int verifyPassword(String account,String password){AuthFailure.require("fixture-password".equals(password),401,"PROOF_INVALID");return "fixture-A".equals(account)?1:2;} + public SessionAuthenticator.Snapshot snapshot(int uid){return new SessionAuthenticator.Snapshot(uid,10,1,true,false,true,1,1,1,1,"fixture-"+uid,"fixture");} + public void switchIdentity(int uid,int role,int school){AuthFailure.require(school==10||school==20,403,"IDENTITY_NOT_ALLOWED");if(switchPublishesVersion.get())db.update("UPDATE mob_auth_epoch SET identity_version=identity_version+1 WHERE env='dev' AND user_id=?",uid);} + }; + service=new MobileIdentityService(db,new TransactionTemplate(new DataSourceTransactionManager(ds)),accounts,Clock.systemUTC()); + } + private static byte[] read(InputStream in) throws Exception {try(InputStream stream=in;ByteArrayOutputStream out=new ByteArrayOutputStream()){byte[] b=new byte[4096];int n;while((n=stream.read(b))!=-1)out.write(b,0,n);return out.toByteArray();}} + T as(String action,Callable work) throws Exception { + RequestAssertion v=new RequestAssertion((e,i,k)->new RequestAssertion.TrustedKey(keys.getPublic(),Collections.singleton("UB"),caps),(k,t)->replay.add(k),Clock.systemUTC(),"ub-test"); + long now=Instant.now().getEpochSecond();String signed=v.sign(keys.getPrivate(),"test","M","UB","dev",Crypto.random(),now,now+30,"POST","/oapi/test",new byte[0],action,""); + MockHttpServletRequest r=new MockHttpServletRequest("POST","/oapi/test");r.addHeader(RequestAssertion.HEADER,signed); + MockHttpServletResponse response=new MockHttpServletResponse();AtomicReference result=new AtomicReference<>(); + new StrongRequestFilter(()->v,x->new StrongRequestFilter.Required("dev","UB",action,"/oapi/test")).doFilter(r,response,(req,res)->{ + try{result.set(work.call());}catch(RuntimeException e){throw e;}catch(Exception e){throw new javax.servlet.ServletException(e);}}); + if(response.getStatus()!=200){String code=new com.fasterxml.jackson.databind.ObjectMapper().readTree(response.getContentAsString()).path("code").asText();throw new AuthFailure(response.getStatus(),code);}return result.get(); + } + String[] proofs(String who) throws Exception {String ch=as("proof.channel.register",()->service.channelProof(Crypto.random(),providerScope,subject,0,"BIND")); + String user=as("proof.user.issue",()->service.passwordProof(ch,who,"fixture-password"));return new String[]{ch,user};} + SessionAuthenticator.Session portal() { + long now=Instant.now().getEpochSecond(); + return new SessionAuthenticator.Session("portal-session","dev","M","h5","cas",null,1,10,1,1,1,0,1,now,now+600,300); + } + void portalState(){db.update("INSERT INTO mob_auth_generation(env,generation,ready) VALUES('dev',1,TRUE)");db.update("INSERT INTO mob_auth_epoch(env,user_id,user_epoch,identity_version) VALUES('dev',1,1,1)");} + @Test void passwordLoginInitializesAuthorityWithoutChannelBinding(){ + db.update("INSERT INTO mob_auth_generation(env,generation,ready) VALUES('dev',3,TRUE)"); + SessionAuthenticator.Snapshot user=service.verifyPortalPassword("dev","fixture-A","fixture-password"); + assertEquals(1,user.userId);assertEquals(3,user.generation);assertEquals(0,user.bindingVersion);assertEquals(1,user.userEpoch); + assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM mob_auth_subject",Integer.class)); + assertEquals(1,db.queryForObject("SELECT COUNT(*) FROM mob_auth_epoch",Integer.class)); + assertEquals(401,assertThrows(AuthFailure.class,()->service.verifyPortalPassword("dev","fixture-A","bad")).status); + assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM mob_auth_proof",Integer.class)); + } + @Test void portalAvailableReturnsRealAuthorityListAndChildSessionCannotSwitch()throws Exception{ + portalState();assertEquals(2,((java.util.List)as("mobile.identity.read",()->service.readPortalIdentity(portal(),true))).size()); + SessionAuthenticator.Session p=portal();SessionAuthenticator.Session child=new SessionAuthenticator.Session(p.id,p.env,p.audience,p.channel,p.subject,"parent",p.userId,p.schoolId,p.roleId,p.userEpoch,p.identityVersion,p.bindingVersion,p.generation,p.issuedAt,p.absoluteExpiresAt,p.idleSeconds); + assertEquals(403,assertThrows(AuthFailure.class,()->as("mobile.identity.switch",()->service.switchPortalIdentity(child,7,20,1,Crypto.random()))).status); + } + @Test void portalSwitchLostResponseRecoversWithoutSecondMutationAndRejectsDifferentTarget()throws Exception{ + portalState();String op=Crypto.random();SessionAuthenticator.Session session=portal(); + Map result=as("mobile.identity.switch",()->service.switchPortalIdentity(session,7,20,1,op)); + assertEquals(2L,result.get("identityVersion")); + assertEquals(new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(result),new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(as("mobile.identity.switch",()->service.switchPortalIdentity(session,7,20,1,op)))); + assertEquals(409,assertThrows(AuthFailure.class,()->as("mobile.identity.switch",()->service.switchPortalIdentity(session,7,10,1,op))).status); + assertEquals(1L,db.queryForObject("SELECT user_epoch FROM mob_auth_epoch WHERE user_id=1",Long.class)); + assertEquals(2L,db.queryForObject("SELECT identity_version FROM mob_auth_epoch WHERE user_id=1",Long.class)); + assertEquals(1,db.queryForObject("SELECT COUNT(*) FROM mob_auth_operation",Integer.class)); + } + @Test void revokedPortalCannotRecoverCommittedSwitch()throws Exception{ + portalState();String op=Crypto.random();SessionAuthenticator.Session session=portal(); + as("mobile.identity.switch",()->service.switchPortalIdentity(session,7,20,1,op)); + db.update("UPDATE mob_auth_epoch SET user_epoch=2 WHERE user_id=1"); + assertEquals(401,assertThrows(AuthFailure.class,()->as("mobile.identity.switch",()->service.switchPortalIdentity(session,7,20,1,op))).status); + } + @Test void stalePortalVersionAndUnauthorizedSchoolNeverCommit()throws Exception{ + portalState();SessionAuthenticator.Session session=portal(); + assertEquals(409,assertThrows(AuthFailure.class,()->as("mobile.identity.switch",()->service.switchPortalIdentity(session,7,20,2,Crypto.random()))).status); + assertEquals(403,assertThrows(AuthFailure.class,()->as("mobile.identity.switch",()->service.switchPortalIdentity(session,7,0,1,Crypto.random()))).status); + assertEquals(403,assertThrows(AuthFailure.class,()->as("mobile.identity.switch",()->service.switchPortalIdentity(session,7,30,1,Crypto.random()))).status); + assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM mob_auth_operation",Integer.class)); + } + @Test void nakedServiceCallCannotReadOrBind() { + assertEquals(401,assertThrows(AuthFailure.class,()->service.bind("raw","raw",Crypto.random())).status); + assertThrows(AuthFailure.class,()->service.resolve("raw"));assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM mob_auth_subject",Integer.class)); + } + @Test void switchReturnsActualVersionWithoutDoubleCountingAuthorityEvent()throws Exception{ + String[] p=proofs("fixture-A");as("channel.bind.password",()->service.bind(p[0],p[1],Crypto.random()));switchPublishesVersion.set(true); + String action=as("proof.action.register",()->service.actionProof(1,Crypto.random(),providerScope,subject,1,"SWITCH"));Map result=as("identity.switch.self",()->service.switchIdentity(action,7,20,1,Crypto.random()));assertEquals(2L,result.get("identityVersion"));assertEquals(2L,db.queryForObject("SELECT identity_version FROM mob_auth_epoch WHERE env='dev' AND user_id=1",Long.class));assertEquals(1L,db.queryForObject("SELECT user_epoch FROM mob_auth_epoch WHERE env='dev' AND user_id=1",Long.class)); + } + @Test void identicalSubjectInDifferentProviderAppNamespacesHasIndependentOwners()throws Exception{ + String second=new ProviderScope("dingtalk","dingtalk","app-A",null).key(); + for(String cap:caps)db.update("INSERT INTO mob_auth_caller_scope(env,app_id,capability,provider_scope) VALUES('dev','M',?,?)",cap,second); + String[] first=proofs("fixture-A");as("channel.bind.password",()->service.bind(first[0],first[1],Crypto.random())); + String ch=as("proof.channel.register",()->service.channelProof(Crypto.random(),second,subject,0,"BIND"));String proof=as("proof.user.issue",()->service.passwordProof(ch,"fixture-B","fixture-password"));as("channel.bind.password",()->service.bind(ch,proof,Crypto.random())); + assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM mob_auth_subject WHERE provider_subject_hash=?",Integer.class,subject)); + assertEquals(2,db.queryForObject("SELECT user_id FROM mob_auth_subject WHERE provider_scope=?",Integer.class,second)); + String spoof=new ProviderScope("wechat","wxgzh","unregistered-app",null).key();assertEquals(403,assertThrows(AuthFailure.class,()->as("proof.channel.register",()->service.channelProof(Crypto.random(),spoof,subject,0,"RESOLVE"))).status); + } + @Test void bindCommitsProofsAndLedgerThenSameOperationRecovers() throws Exception { + String[] p=proofs("fixture-A");String op=Crypto.random();Map first=as("channel.bind.password",()->service.bind(p[0],p[1],op)); + assertEquals(first,as("channel.bind.password",()->service.bind(p[0],p[1],op))); + assertEquals(1,db.queryForObject("SELECT COUNT(*) FROM mob_auth_subject WHERE user_id=1",Integer.class)); + assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM mob_auth_proof WHERE consumed=TRUE",Integer.class)); + assertEquals(1,db.queryForObject("SELECT COUNT(*) FROM mob_auth_operation",Integer.class)); + assertEquals(409,assertThrows(AuthFailure.class,()->as("channel.bind.password",()->service.bind(p[0],"different-proof",op))).status); + } + @Test void concurrentProofConsumption100AttemptsOneCommit() throws Exception { + String[] p=proofs("fixture-A");ExecutorService pool=Executors.newFixedThreadPool(12);AtomicInteger ok=new AtomicInteger(); + try {List> jobs=new ArrayList<>();for(int i=0;i<100;i++)jobs.add(()->{try{as("channel.bind.password",()->service.bind(p[0],p[1],Crypto.random()));ok.incrementAndGet();}catch(AuthFailure expected){}return null;}); + for(Future f:pool.invokeAll(jobs))f.get();assertEquals(1,ok.get());assertEquals(1,db.queryForObject("SELECT COUNT(*) FROM mob_auth_operation",Integer.class)); + System.out.println("UB proof concurrency: attempts=100 committed="+ok.get()); + }finally{pool.shutdownNow();} + } + @Test void competingUsersNeverOverwriteOwner() throws Exception { + String[] a=proofs("fixture-A"),b=proofs("fixture-B");as("channel.bind.password",()->service.bind(a[0],a[1],Crypto.random())); + assertEquals(409,assertThrows(AuthFailure.class,()->as("channel.bind.password",()->service.bind(b[0],b[1],Crypto.random()))).status); + assertEquals(1,db.queryForObject("SELECT user_id FROM mob_auth_subject",Integer.class)); + } + @Test void wrongActionAndExpiredProofsRejectedWithoutWrites() throws Exception { + String[] p=proofs("fixture-A");db.update("UPDATE mob_auth_proof SET expires_at=0 WHERE handle_hash=?",Crypto.hash(p[1])); + assertEquals(401,assertThrows(AuthFailure.class,()->as("channel.bind.password",()->service.bind(p[0],p[1],Crypto.random()))).status); + assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM mob_auth_subject",Integer.class)); + assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM mob_auth_proof WHERE consumed=TRUE",Integer.class)); + assertThrows(AuthFailure.class,()->as("channel.resolve",()->service.resolve(p[0]))); + } + @Test void proofFromAnotherCallerOrEnvironmentRejected() throws Exception { + String[] p=proofs("fixture-A");db.update("UPDATE mob_auth_proof SET env='prod' WHERE handle_hash=?",Crypto.hash(p[1])); + assertEquals(401,assertThrows(AuthFailure.class,()->as("channel.bind.password",()->service.bind(p[0],p[1],Crypto.random()))).status); + } + @Test void unbindRetainsTombstoneAndIncrementsEpoch() throws Exception { + String[] p=proofs("fixture-A");as("channel.bind.password",()->service.bind(p[0],p[1],Crypto.random())); + String a=as("proof.action.register",()->service.actionProof(1,Crypto.random(),providerScope,subject,1,"UNBIND")); + String r=as("proof.user.issue",()->service.reauth(a,"fixture-A","fixture-password")); + as("channel.unbind.self",()->service.unbind(a,r,Crypto.random())); + assertNull(db.queryForObject("SELECT user_id FROM mob_auth_subject",Integer.class)); + assertEquals(2L,db.queryForObject("SELECT version FROM mob_auth_subject",Long.class)); + assertEquals(2L,db.queryForObject("SELECT user_epoch FROM mob_auth_epoch",Long.class)); + } + @Test void ordinaryApplicationWithSignatureButNoScopeDenied() throws Exception { + db.update("DELETE FROM mob_auth_caller_scope"); + assertEquals(403,assertThrows(AuthFailure.class,()->as("proof.channel.register",()->service.channelProof(Crypto.random(),providerScope,subject,0,"BIND"))).status); + assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM mob_auth_proof",Integer.class)); + } + @Test void crossUserReauthenticationDenied() throws Exception { + String[] p=proofs("fixture-A");as("channel.bind.password",()->service.bind(p[0],p[1],Crypto.random())); + String a=as("proof.action.register",()->service.actionProof(1,Crypto.random(),providerScope,subject,1,"UNBIND")); + assertEquals(403,assertThrows(AuthFailure.class,()->as("proof.user.issue",()->service.reauth(a,"fixture-B","fixture-password"))).status); + assertEquals(1,db.queryForObject("SELECT user_id FROM mob_auth_subject",Integer.class)); + } +} diff --git a/src/test/java/com/yunhai/userbase/mobile/MobilePasswordAuthorityTest.java b/src/test/java/com/yunhai/userbase/mobile/MobilePasswordAuthorityTest.java new file mode 100644 index 0000000..a74e04a --- /dev/null +++ b/src/test/java/com/yunhai/userbase/mobile/MobilePasswordAuthorityTest.java @@ -0,0 +1,38 @@ +package com.yunhai.userbase.mobile; +import com.yunhai.platform.mobile.*; +import com.yunhai.platform.utils.model.BoaRoleInfo; +import com.yunhai.userbase.entity.SysUser; +import com.yunhai.userbase.enums.*; +import com.yunhai.userbase.repository.SysUserRepository; +import com.yunhai.userbase.service.impl.IdentityService; +import com.yunhai.userbase.service.IGroupAuthzService; +import org.junit.jupiter.api.*; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import java.util.*; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +/** Actual configured Accounts implementation + BCrypt. JPA repositories are fixtures, transaction tables are H2. */ +class MobilePasswordAuthorityTest { + MobileIdentityService service;SysUser user;IdentityService identities; + @BeforeEach void setup(){ + DriverManagerDataSource ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";DB_CLOSE_DELAY=-1","sa","");JdbcTemplate db=new JdbcTemplate(ds); + db.execute("CREATE TABLE sys_user(id INT PRIMARY KEY)");db.execute("INSERT INTO sys_user VALUES(12)"); + db.execute("CREATE TABLE mob_auth_epoch(env VARCHAR(64),user_id INT,user_epoch BIGINT,identity_version BIGINT)"); + db.execute("CREATE TABLE mob_auth_generation(env VARCHAR(64),generation BIGINT,ready BOOLEAN)");db.execute("INSERT INTO mob_auth_generation VALUES('dev',3,TRUE)"); + BCryptPasswordEncoder encoder=new BCryptPasswordEncoder();user=new SysUser();user.setId(12);user.setAccount("fixture");user.setRealName("Fixture");user.setPassword(encoder.encode("fixture-password"));user.setDisabled(StatusEnum.NORMAL);user.setExpired(false);user.setType(UserTypeEnum.NORMAL); + SysUserRepository users=mock(SysUserRepository.class);when(users.findByAccount("fixture")).thenReturn(user);when(users.findById(12)).thenReturn(Optional.of(user)); + identities=mock(IdentityService.class);BoaRoleInfo role=new BoaRoleInfo();role.setSchId(10);role.setRoleId(1);when(identities.getCurrentForAuthentication(12)).thenReturn(role);when(identities.listAvailable(12)).thenReturn(Collections.singletonList(role)); + service=new MobileIdentityConfiguration().mobileIdentityService(db,new DataSourceTransactionManager(ds),users,encoder,identities,mock(IGroupAuthzService.class)); + } + @Test void existingPasswordHashIsUsedAndNeverReturned(){SessionAuthenticator.Snapshot a=service.verifyPortalPassword("dev","fixture","fixture-password");assertEquals(12,a.userId);assertEquals(3,a.generation);assertEquals("fixture",a.account);} + @Test void unknownWrongDisabledAndExpiredAccountsAllReject(){ + assertEquals(401,assertThrows(AuthFailure.class,()->service.verifyPortalPassword("dev","unknown","fixture-password")).status); + assertEquals(401,assertThrows(AuthFailure.class,()->service.verifyPortalPassword("dev","fixture","bad")).status); + user.setDisabled(StatusEnum.DISABLED);assertEquals(401,assertThrows(AuthFailure.class,()->service.verifyPortalPassword("dev","fixture","fixture-password")).status); + user.setDisabled(StatusEnum.NORMAL);user.setExpired(true);assertEquals(401,assertThrows(AuthFailure.class,()->service.verifyPortalPassword("dev","fixture","fixture-password")).status); + } + @Test void noCurrentQualificationCannotMintPortalSession(){when(identities.listAvailable(12)).thenReturn(Collections.emptyList());assertEquals(403,assertThrows(AuthFailure.class,()->service.verifyPortalPassword("dev","fixture","fixture-password")).status);} +} diff --git a/src/test/java/com/yunhai/userbase/service/impl/ApplicationInfoAppCodeTest.java b/src/test/java/com/yunhai/userbase/service/impl/ApplicationInfoAppCodeTest.java index 629f40b..d4899ba 100644 --- a/src/test/java/com/yunhai/userbase/service/impl/ApplicationInfoAppCodeTest.java +++ b/src/test/java/com/yunhai/userbase/service/impl/ApplicationInfoAppCodeTest.java @@ -24,9 +24,15 @@ class ApplicationInfoAppCodeTest { repository = mock(ApplicationInfoRepository.class); ReflectionTestUtils.setField(service, "applicationInfoRepository", repository); ReflectionTestUtils.setField(service, "eventPublisher", mock(ApplicationEventPublisher.class)); + com.yunhai.userbase.repository.ApplicationKeyReservationRepository keys = mock(com.yunhai.userbase.repository.ApplicationKeyReservationRepository.class); + com.yunhai.userbase.entity.ApplicationKeyReservation reservation = new com.yunhai.userbase.entity.ApplicationKeyReservation(); + ReflectionTestUtils.setField(reservation, "appKey", 100019); + when(keys.saveAndFlush(any())).thenReturn(reservation); + ReflectionTestUtils.setField(service, "applicationKeys", keys); entity = new ApplicationInfo(); entity.setId(1); entity.setAppCode("svc-hall"); when(repository.findById(1)).thenReturn(Optional.of(entity)); + when(repository.existsById(1)).thenReturn(true); when(repository.save(any(ApplicationInfo.class))).thenAnswer(invocation -> invocation.getArgument(0)); } @@ -80,5 +86,6 @@ class ApplicationInfoAppCodeTest { assertSame(entity, service.save(entity)); entity.setId(null); assertSame(entity, service.save(entity)); + assertEquals(100019, entity.getAppKey()); } } diff --git a/src/test/java/com/yunhai/userbase/service/impl/ApplicationKeyGateTest.java b/src/test/java/com/yunhai/userbase/service/impl/ApplicationKeyGateTest.java new file mode 100644 index 0000000..f2f7262 --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/ApplicationKeyGateTest.java @@ -0,0 +1,109 @@ +package com.yunhai.userbase.service.impl; + +import com.yunhai.platform.utils.exception.DException; +import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.entity.ApplicationKeyReservation; +import com.yunhai.userbase.model.application.ApplicationInfoRequest; +import com.yunhai.userbase.repository.ApplicationInfoRepository; +import com.yunhai.userbase.repository.ApplicationKeyReservationRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * P1-3 应用凭据机械门禁:历史身份审计未完成(哨兵行在)时,任何新增应用入口都必须 + * 实际拒绝发放新 appKey——不依赖操作人员阅读迁移脚本注释;门禁状态不可读同样 fail-closed。 + * 验收口径=《FL核心命令协议增量独立审查报告-20261026》P1-3 修复后必须通过项。 + */ +@DisplayName("应用凭据 key 发放机械门禁(P1-3 fail-closed)") +class ApplicationKeyGateTest { + + private ApplicationInfoService service; + private ApplicationInfoRepository repository; + private ApplicationKeyReservationRepository keys; + + @BeforeEach + void setUp() { + service = new ApplicationInfoService(); + repository = mock(ApplicationInfoRepository.class); + keys = mock(ApplicationKeyReservationRepository.class); + ReflectionTestUtils.setField(service, "applicationInfoRepository", repository); + ReflectionTestUtils.setField(service, "eventPublisher", mock(ApplicationEventPublisher.class)); + ReflectionTestUtils.setField(service, "applicationKeys", keys); + when(repository.save(any(ApplicationInfo.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(repository.existsByName(any())).thenReturn(false); + } + + private ApplicationKeyReservation reservation(int key) { + ApplicationKeyReservation r = new ApplicationKeyReservation(); + ReflectionTestUtils.setField(r, "appKey", key); + return r; + } + + private ApplicationInfoRequest request(String name) { + ApplicationInfoRequest r = new ApplicationInfoRequest(); + r.setName(name); + return r; + } + + @Test + @DisplayName("哨兵行在(审计未完成):新增应用拒绝发放,且不消耗保留段 key") + void gateClosedRefusesAllocationWithoutBurningAReservation() { + when(keys.existsById(ApplicationKeyReservation.GATE_CLOSED_SENTINEL)).thenReturn(true); + + assertThrows(DException.class, () -> service.add(request("门禁期应用"))); + verify(keys, never()).saveAndFlush(any()); + verify(repository, never()).save(any()); + } + + @Test + @DisplayName("门禁解除(哨兵已摘):新增应用正常发放保留段 key") + void gateOpenAllocatesFromReservation() { + when(keys.existsById(ApplicationKeyReservation.GATE_CLOSED_SENTINEL)).thenReturn(false); + when(keys.saveAndFlush(any())).thenReturn(reservation(100019)); + + service.add(request("审计后应用")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ApplicationInfo.class); + verify(repository).save(captor.capture()); + assertEquals(100019, captor.getValue().getAppKey()); + } + + @Test + @DisplayName("门禁状态不可读(表缺席/库异常):fail-closed 拒绝,不发放") + void gateStateUnreadableFailsClosed() { + when(keys.existsById(ApplicationKeyReservation.GATE_CLOSED_SENTINEL)) + .thenThrow(new RuntimeException("table missing")); + + assertThrows(DException.class, () -> service.add(request("降级期应用"))); + verify(keys, never()).saveAndFlush(any()); + } + + @Test + @DisplayName("保留段之下的 key(表未迁移/AUTO_INCREMENT 种子丢失):熔断拒绝") + void subFloorKeyStillRejected() { + when(keys.existsById(ApplicationKeyReservation.GATE_CLOSED_SENTINEL)).thenReturn(false); + when(keys.saveAndFlush(any())).thenReturn(reservation(1)); + + assertThrows(DException.class, () -> service.add(request("未迁移库应用"))); + } + + @Test + @DisplayName("通用 save 新增路径同样受门禁约束(双入口全覆盖)") + void genericSavePathAlsoGated() { + when(keys.existsById(ApplicationKeyReservation.GATE_CLOSED_SENTINEL)).thenReturn(true); + + ApplicationInfo fresh = new ApplicationInfo(); + fresh.setName("门禁期新应用"); + assertThrows(DException.class, () -> service.save(fresh)); + verify(keys, never()).saveAndFlush(any()); + } +}