feat(mobile-auth): MOB-AUTH-03/04 移动认证入库(含 StringRedisTemplate @Qualifier 修复)
- 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 已有同字节制品)
This commit is contained in:
@@ -35,6 +35,8 @@
|
|||||||
</dependencyManagement>
|
</dependencyManagement>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
|
||||||
|
<dependency><groupId>com.yunhai.platform</groupId><artifactId>yh-mobile-auth</artifactId><version>1.1.0-mobauth03-rc.1</version></dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
@@ -268,6 +270,11 @@
|
|||||||
<directory>C:/ub-build2/userbase</directory>
|
<directory>C:/ub-build2/userbase</directory>
|
||||||
</build>
|
</build>
|
||||||
</profile>
|
</profile>
|
||||||
|
|
||||||
|
<profile>
|
||||||
|
<id>mobauth03</id>
|
||||||
|
<build><directory>${mobauth03.output}/${project.artifactId}</directory></build>
|
||||||
|
</profile>
|
||||||
</profiles>
|
</profiles>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -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<StrongRequestFilter> userBaseMobileStrong(
|
||||||
|
@org.springframework.beans.factory.annotation.Qualifier("stringRedisTemplate") ObjectProvider<StringRedisTemplate> 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<StrongRequestFilter> bean=new FilterRegistrationBean<>(filter);
|
||||||
|
bean.setOrder(Ordered.HIGHEST_PRECEDENCE+3);bean.addUrlPatterns("/*");return bean;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,7 +68,9 @@ public class OpenApiAuthEnforceConfiguration {
|
|||||||
throws IOException, ServletException {
|
throws IOException, ServletException {
|
||||||
HttpServletRequest req = (HttpServletRequest) request;
|
HttpServletRequest req = (HttpServletRequest) request;
|
||||||
HttpServletResponse res = (HttpServletResponse) response;
|
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");
|
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing app signature");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import com.yunhai.platform.utils.enums.IEnum;
|
|||||||
import com.yunhai.platform.utils.model.BoaAppType;
|
import com.yunhai.platform.utils.model.BoaAppType;
|
||||||
import com.yunhai.userbase.config.Config;
|
import com.yunhai.userbase.config.Config;
|
||||||
import com.yunhai.userbase.entity.ApplicationInfo;
|
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.AppIconMapVo;
|
||||||
import com.yunhai.userbase.model.application.ApplicationInfoVo;
|
import com.yunhai.userbase.model.application.ApplicationInfoVo;
|
||||||
import com.yunhai.userbase.service.IAppCategoryService;
|
import com.yunhai.userbase.service.IAppCategoryService;
|
||||||
import com.yunhai.userbase.service.IApplicationInfoService;
|
import com.yunhai.userbase.service.IApplicationInfoService;
|
||||||
import com.yunhai.userbase.service.IGroupAuthzService;
|
import com.yunhai.userbase.service.IGroupAuthzService;
|
||||||
|
import com.yunhai.platform.utils.exception.DException;
|
||||||
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.BeanUtils;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -38,6 +40,9 @@ public class AppController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private IGroupAuthzService groupAuthzService;
|
private IGroupAuthzService groupAuthzService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private com.yunhai.userbase.service.ISysGroupService sysGroupService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按主键 ID 查询应用信息(带缓存)。<br>
|
* 按主键 ID 查询应用信息(带缓存)。<br>
|
||||||
* 与客户端契约 {@code GET /oapi/app/{appId}} 对应;查不到返回 null(HTTP 200 + data:null 信封)。<br>
|
* 与客户端契约 {@code GET /oapi/app/{appId}} 对应;查不到返回 null(HTTP 200 + data:null 信封)。<br>
|
||||||
@@ -158,4 +163,29 @@ public class AppController {
|
|||||||
public List<AppIconMapVo> iconMap() {
|
public List<AppIconMapVo> iconMap() {
|
||||||
return applicationInfoService.listIconMap();
|
return applicationInfoService.listIconMap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用×学校单点开通判定(2026-10-25 T01 最小只读口)。<br>
|
||||||
|
* 权威事实源 = {@code sys_group_app_rel}(组应用开通表,学校组单点、不沿祖先、不并角色模块),
|
||||||
|
* 与 {@code GET /oapi/app/list-by-school} 同一口径,只是收敛成 boolean 判定——
|
||||||
|
* form-center/flow-center 等下游把本端点作为「该应用是否在该校开通」的平台资格第二重证明
|
||||||
|
* (fail-closed:本端点不可达时下游必须拒绝,不得降级为"仅验签通过")。<br>
|
||||||
|
* 应用不存在(含已注销)返回 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +1,19 @@
|
|||||||
package com.yunhai.userbase.controller.oapi;
|
package com.yunhai.userbase.controller.oapi;
|
||||||
|
import com.yunhai.platform.mobile.*;
|
||||||
import com.yunhai.platform.utils.model.BoaUser;
|
import com.yunhai.userbase.mobile.MobileIdentityService;
|
||||||
import com.yunhai.userbase.common.annotation.OapiLog;
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
import com.yunhai.userbase.config.Config;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import com.yunhai.userbase.model.channel.ChannelBindByPhoneRequest;
|
import java.util.Map;
|
||||||
import com.yunhai.userbase.model.channel.ChannelBindRequest;
|
/** Legacy paths retained; naked phone/account/openid authority is retired. */
|
||||||
import com.yunhai.userbase.model.channel.ChannelUnbindRequest;
|
@RestController @RequestMapping("/oapi/channel")
|
||||||
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)。<br>
|
|
||||||
* <br>
|
|
||||||
* 供 SDK 登录端点(门户 / 各子应用)经 yh-app-auth 签名调用({@code /oapi/channel/...});
|
|
||||||
* 签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制。<br>
|
|
||||||
* 绑定身份以服务端校验为准:openid/unionId 来自一次性 loginTicket,账号密码/手机号在本服务校验,
|
|
||||||
* 前端永不接触 openid/unionid(抢绑防护,方案 §3.4)。<br>
|
|
||||||
* POST 写操作由 {@code OapiLogAspect} 自动记录审计日志。
|
|
||||||
*
|
|
||||||
* @author chenx
|
|
||||||
* @date 2026/9/25
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping(Config.OPEN_API_PATH + "channel/")
|
|
||||||
public class ChannelBindController {
|
public class ChannelBindController {
|
||||||
|
private final ObjectProvider<MobileIdentityService> provider;
|
||||||
@Autowired
|
public ChannelBindController(ObjectProvider<MobileIdentityService> provider){this.provider=provider;}
|
||||||
private ChannelBindService channelBindService;
|
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;}
|
||||||
* 按 (channel, openid) 查已绑定用户。
|
@GetMapping("/user") public Map<String,Object> user(@RequestHeader("X-YH-Proof") String proof){return service().resolve(proof);}
|
||||||
*
|
@PostMapping("/bind") public Map<String,Object> bind(@RequestBody Bind b){return service().bind(b.channelProof,b.userProof,b.operationId);}
|
||||||
* @return 已绑定时返回用户明细(含 currentRole);未绑定返回 null(data 为 null,code=0)
|
@PostMapping("/bindByPhone") public void phone(){Contexts.requireCaller("channel.bind.phone");throw new AuthFailure(403,"METHOD_DISABLED");}
|
||||||
*/
|
@PostMapping("/unbind") public Map<String,Object> unbind(@RequestBody Unbind b){return service().unbind(b.userActionProof,b.reauthProof,b.operationId);}
|
||||||
@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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,94 +1,16 @@
|
|||||||
package com.yunhai.userbase.controller.oapi;
|
package com.yunhai.userbase.controller.oapi;
|
||||||
|
import com.yunhai.platform.mobile.*;
|
||||||
import com.yunhai.platform.utils.exception.DException;
|
import com.yunhai.userbase.mobile.MobileIdentityService;
|
||||||
import com.yunhai.platform.utils.model.BoaRoleInfo;
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
import com.yunhai.userbase.common.context.CallerAppContext;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import com.yunhai.userbase.config.Config;
|
import java.util.Map;
|
||||||
import com.yunhai.userbase.model.identity.IdentitySwitchOapiRequest;
|
@RestController @RequestMapping("/oapi/identity")
|
||||||
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/...})。<br>
|
|
||||||
* <br>
|
|
||||||
* 契约统一 {@link BoaRoleInfo}(roleId/roleName/schId/schName/childId/childName),
|
|
||||||
* 与 {@code /oapi/user/roles}、{@code BoaUser.currentRole} 完全同构,前端零转换。<br>
|
|
||||||
* 签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制;<br>
|
|
||||||
* <b>切换(switch)</b>额外受 {@code yh.identity.switch-allowed-app-keys} 白名单约束(AppKey 级闸门),
|
|
||||||
* 且目标用户必须真实存在于其身份集({@code sys_user_role_rel} 三元组校验,防越权切换他人)。<br>
|
|
||||||
* POST 写操作由 {@code OapiLogAspect} 自动记录审计日志。
|
|
||||||
*
|
|
||||||
* @author chenx
|
|
||||||
* @date 2026/8/27
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping(Config.OPEN_API_PATH + "identity/")
|
|
||||||
public class IdentityController {
|
public class IdentityController {
|
||||||
|
private final ObjectProvider<MobileIdentityService> provider;
|
||||||
@Autowired
|
public IdentityController(ObjectProvider<MobileIdentityService> provider){this.provider=provider;}
|
||||||
private IdentityService identityService;
|
private MobileIdentityService service(){MobileIdentityService s=provider.getIfAvailable();AuthFailure.require(s!=null,503,"AUTHORITY_UNAVAILABLE");return s;}
|
||||||
|
@GetMapping("/current") public Map<String,Object> current(@RequestHeader("X-YH-Proof") String proof){return service().readIdentity(proof,false);}
|
||||||
/** 允许调用 switch 的应用 appKey 白名单(逗号分隔,缺省空 = 禁闭) */
|
@GetMapping("/available") public Map<String,Object> available(@RequestHeader("X-YH-Proof") String proof){return service().readIdentity(proof,true);}
|
||||||
@Value("${yh.identity.switch-allowed-app-keys:}")
|
public static class Switch {public String userActionProof,operationId;public int roleId,schoolId;public long expectedIdentityVersion;}
|
||||||
private String switchAllowedAppKeys;
|
@PostMapping("/switch") public Map<String,Object> change(@RequestBody Switch b){return service().switchIdentity(b.userActionProof,b.roleId,b.schoolId,b.expectedIdentityVersion,b.operationId);}
|
||||||
|
|
||||||
/**
|
|
||||||
* 查询用户当前激活身份:优先 sys_user_current_role,未切换时兜底主身份(动态解析);
|
|
||||||
* 无任何身份返回 null。
|
|
||||||
*/
|
|
||||||
@GetMapping("current")
|
|
||||||
public BoaRoleInfo current(@RequestParam("userId") Integer userId) {
|
|
||||||
return identityService.getCurrent(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 用户全部可选身份(正常态),按 主身份所在学校置顶 → 其他学校 schoolId 升序 → 校内 roleId 升序,
|
|
||||||
* 供「切换身份」页渲染。
|
|
||||||
*/
|
|
||||||
@GetMapping("available")
|
|
||||||
public List<BoaRoleInfo> 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<Integer> 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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<MobileIdentityService> services;
|
||||||
|
private final String env; private final Set<String> portals;
|
||||||
|
public PasswordVerificationController(ObjectProvider<MobileIdentityService> 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<Map<String,Object>> failure(AuthFailure e) {
|
||||||
|
Map<String,Object> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,17 @@ import java.util.List;
|
|||||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||||
public class ApplicationInfo extends BaseEntity {
|
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起自增。<br>
|
* appKey 系统生成唯一标识,整型,从100000起自增。<br>
|
||||||
*/
|
*/
|
||||||
@NotNull
|
@NotNull
|
||||||
@Column(name = "app_key", unique = true, nullable = false)
|
@Column(name = "app_key", unique = true, nullable = false, updatable = false)
|
||||||
private Integer appKey;
|
private Integer appKey;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -174,4 +185,4 @@ public class ApplicationInfo extends BaseEntity {
|
|||||||
@Column(name = "push_secret")
|
@Column(name = "push_secret")
|
||||||
private String pushSecret;
|
private String pushSecret;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;}
|
||||||
|
}
|
||||||
@@ -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<BoaRoleInfo> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<com.yunhai.platform.utils.model.BoaRoleInfo> 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<String,Object> 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<String,Object> 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<String,Object> resolve(String handle) {
|
||||||
|
CallerAppContext c=caller("channel.resolve");return tx.execute(s->{Map<String,Object> 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<String,Object> 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<String,Object> 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<String,Object> 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<String,Object> 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<String,Object> 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<String,Object> 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<String,Object> r=result(uid);r.put("identityVersion",updated);return r;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
public Map<String,Object> readIdentity(String handle,boolean available) {
|
||||||
|
CallerAppContext c=caller("identity.read.self");return tx.execute(s->{Map<String,Object> p=proof(c,handle,available?"READ_AVAILABLE":"READ_CURRENT",true);
|
||||||
|
SessionAuthenticator.Snapshot a=accounts.snapshot((int)number(p,"user_id"));Map<String,Object> 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<String,Object> 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<String,Object> 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<Map<String,Object>> 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<String,Object> 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<String,Object> 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<String,Object> 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<String,Object> 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<String,Object> 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<Map<String,Object>> 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<Map<String,Object>> masked=new ArrayList<>();for(Map<String,Object> row:rows){Map<String,Object> 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<String,Object> 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<String,Object> operation(CallerAppContext c,String op,String hash,Supplier<Map<String,Object>> 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<String,Object> saved=existing(c,op,hash);if(saved!=null)return saved;
|
||||||
|
Map<String,Object> 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<String,Object> existing(CallerAppContext c,String op,String hash) {
|
||||||
|
List<Map<String,Object>> 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<String,Object> proof(CallerAppContext c,String raw,String action,boolean consume) {
|
||||||
|
AuthFailure.require(raw!=null,401,"PROOF_INVALID");List<Map<String,Object>> rows=db.queryForList("SELECT * FROM mob_auth_proof WHERE handle_hash=?",Crypto.hash(raw));
|
||||||
|
AuthFailure.require(rows.size()==1,401,"PROOF_INVALID");Map<String,Object> 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<Long> 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<Integer> 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<Long> 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<String,Object> a,Map<String,Object> 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<String,Object> result(int uid) {Map<String,Object> r=new LinkedHashMap<>();r.put("userId",uid);r.put("status","COMMITTED");return r;}
|
||||||
|
private static Object get(Map<String,Object> p,String k) {return p.containsKey(k)?p.get(k):p.get(k.toUpperCase(Locale.ROOT));}
|
||||||
|
private static String str(Map<String,Object> p,String k) {Object v=get(p,k);return v==null?null:String.valueOf(v);}
|
||||||
|
private static long number(Map<String,Object> p,String k) {return ((Number)get(p,k)).longValue();}
|
||||||
|
}
|
||||||
@@ -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<MobileIdentityService> provider;
|
||||||
|
public MobileProofController(ObjectProvider<MobileIdentityService> 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<String,String> 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<String,String> 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<String,String> 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<String,Object> result(@RequestBody Operation b){return service().recover(b.operationId,b.requestHash);}
|
||||||
|
}
|
||||||
@@ -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<Map<String,Object>> unavailable(org.springframework.dao.DataAccessException e) {
|
||||||
|
return failure(new AuthFailure(503,"AUTHORITY_UNAVAILABLE"));
|
||||||
|
}
|
||||||
|
@ExceptionHandler(AuthFailure.class) public ResponseEntity<Map<String,Object>> failure(AuthFailure e) {
|
||||||
|
Map<String,Object> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");}
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ import java.util.List;
|
|||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
public class ApplicationInfoVo {
|
public class ApplicationInfoVo {
|
||||||
|
/** Stable, non-secret application incarnation used for source isolation. */
|
||||||
|
private String sourcePrincipalId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主键ID。<br>
|
* 主键ID。<br>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -33,14 +33,9 @@ public interface ApplicationInfoRepository extends JpaRepository<ApplicationInf
|
|||||||
|
|
||||||
boolean existsByNameAndIdNot(String name, Integer id);
|
boolean existsByNameAndIdNot(String name, Integer id);
|
||||||
|
|
||||||
/**
|
// findMaxAppKey 已删(2026-10-26 P2-6):凭据 key 一律由 application_key_reservation 的
|
||||||
* 查询当前最大的 appKey 值。<br>
|
// insert-only 永久保留行分配(ApplicationInfoService.allocateAppKey),存活表 MAX 不得
|
||||||
* 用于新增应用时生成从 100000 起自增的整型 appKey。<br>
|
// 作为分配依据——已删除应用可能占用更高 key,max(存活)+1 会复用注销凭据。
|
||||||
*
|
|
||||||
* @return 当前最大 appKey,无记录时返回 null
|
|
||||||
*/
|
|
||||||
@Query("SELECT MAX(a.appKey) FROM ApplicationInfo a")
|
|
||||||
Integer findMaxAppKey();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按整型 appKey 查单个应用(验签兜底端点直连读取用)。<br>
|
* 按整型 appKey 查单个应用(验签兜底端点直连读取用)。<br>
|
||||||
|
|||||||
@@ -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<ApplicationKeyReservation,Integer> { }
|
||||||
@@ -68,7 +68,7 @@ public interface IApplicationInfoService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 新增应用信息。<br>
|
* 新增应用信息。<br>
|
||||||
* 由入参构造实体并落库;appKey 为整型,从 100000 起按数据库最大值自增,appSecret 为 32 位 UUID。<br>
|
* 由入参构造实体并落库;appKey 由 application_key_reservation 永久保留行分配(受历史身份审计门禁约束,见 allocateAppKey),appSecret 为 32 位 UUID。<br>
|
||||||
*
|
*
|
||||||
* @param request 应用新增入参
|
* @param request 应用新增入参
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.yunhai.userbase.service.impl;
|
|||||||
|
|
||||||
import com.yunhai.userbase.entity.AppModule;
|
import com.yunhai.userbase.entity.AppModule;
|
||||||
import com.yunhai.userbase.entity.ApplicationInfo;
|
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||||
|
import com.yunhai.userbase.entity.ApplicationKeyReservation;
|
||||||
import com.yunhai.userbase.entity.SysAppRole;
|
import com.yunhai.userbase.entity.SysAppRole;
|
||||||
import com.yunhai.userbase.constant.AppNature;
|
import com.yunhai.userbase.constant.AppNature;
|
||||||
import com.yunhai.userbase.repository.AppModuleRepository;
|
import com.yunhai.userbase.repository.AppModuleRepository;
|
||||||
@@ -51,6 +52,31 @@ public class ApplicationInfoService implements IApplicationInfoService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private ApplicationInfoRepository applicationInfoRepository;
|
private ApplicationInfoRepository applicationInfoRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private com.yunhai.userbase.repository.ApplicationKeyReservationRepository applicationKeys;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发放应用凭据 key(P1-3 机械门禁,fail-closed):<br>
|
||||||
|
* ① 哨兵行({@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
|
@Autowired
|
||||||
private AppModuleRepository appModuleRepository;
|
private AppModuleRepository appModuleRepository;
|
||||||
|
|
||||||
@@ -144,10 +170,16 @@ public class ApplicationInfoService implements IApplicationInfoService {
|
|||||||
* 通用保存:方法返回后发布变更事件,缓存失效由 AFTER_COMMIT 监听器统一执行。
|
* 通用保存:方法返回后发布变更事件,缓存失效由 AFTER_COMMIT 监听器统一执行。
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
|
@Transactional
|
||||||
public ApplicationInfo save(ApplicationInfo info) {
|
public ApplicationInfo save(ApplicationInfo info) {
|
||||||
if (info.getId() != null) {
|
if (info.getId() != null) {
|
||||||
|
if (!applicationInfoRepository.existsById(info.getId())) {
|
||||||
|
throw new DException("应用不存在;新增必须使用服务端生成的主体和凭据");
|
||||||
|
}
|
||||||
requireUnchangedAppCode(applicationInfoRepository.findPersistedAppCodeById(info.getId()),
|
requireUnchangedAppCode(applicationInfoRepository.findPersistedAppCodeById(info.getId()),
|
||||||
info.getAppCode());
|
info.getAppCode());
|
||||||
|
} else {
|
||||||
|
info.setAppKey(allocateAppKey());
|
||||||
}
|
}
|
||||||
ApplicationInfo saved = applicationInfoRepository.save(info);
|
ApplicationInfo saved = applicationInfoRepository.save(info);
|
||||||
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved));
|
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved));
|
||||||
@@ -182,10 +214,8 @@ public class ApplicationInfoService implements IApplicationInfoService {
|
|||||||
ApplicationInfo entity = new ApplicationInfo();
|
ApplicationInfo entity = new ApplicationInfo();
|
||||||
BeanUtils.copyProperties(request, entity);
|
BeanUtils.copyProperties(request, entity);
|
||||||
entity.setId(null);
|
entity.setId(null);
|
||||||
// appKey:整型,从 100000 起,取数据库现有最大 appKey + 1
|
// Permanent reservations serialize allocation and survive deletion/restart, unlike max(live keys)+1.
|
||||||
Integer maxAppKey = applicationInfoRepository.findMaxAppKey();
|
entity.setAppKey(allocateAppKey());
|
||||||
Integer appKey = (maxAppKey == null) ? 100000 : maxAppKey + 1;
|
|
||||||
entity.setAppKey(appKey);
|
|
||||||
// appSecret:32 位 UUID(去除连字符)
|
// appSecret:32 位 UUID(去除连字符)
|
||||||
entity.setAppSecret(UUID.randomUUID().toString().replace("-", ""));
|
entity.setAppSecret(UUID.randomUUID().toString().replace("-", ""));
|
||||||
ApplicationInfo saved = applicationInfoRepository.save(entity);
|
ApplicationInfo saved = applicationInfoRepository.save(entity);
|
||||||
|
|||||||
@@ -136,10 +136,14 @@ public class IdentityService {
|
|||||||
return null;
|
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 正常态中存在,
|
* 切换身份:校验 (userId, roleId, schoolId) 在 sys_user_role_rel 正常态中存在,
|
||||||
* 再 UPSERT sys_user_current_role(按 userId 唯一)。
|
* 再 UPSERT sys_user_current_role(按 userId 唯一)。
|
||||||
*/
|
*/
|
||||||
|
@org.springframework.transaction.annotation.Transactional
|
||||||
public BoaRoleInfo switchIdentity(Integer userId, Integer roleId, Integer schoolId) {
|
public BoaRoleInfo switchIdentity(Integer userId, Integer roleId, Integer schoolId) {
|
||||||
// 校验目标身份确属该用户且正常态,防越权切换
|
// 校验目标身份确属该用户且正常态,防越权切换
|
||||||
SysUserRoleRel rel = sysUserRoleRelRepository
|
SysUserRoleRel rel = sysUserRoleRelRepository
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
-- 用于对外发起 yh-app-auth 签名的应用签名请求;因此固定占用 appKey=100000。
|
-- 用于对外发起 yh-app-auth 签名的应用签名请求;因此固定占用 appKey=100000。
|
||||||
-- 3. appSecret 每次执行脚本若不存在时会重新生成一个 UUID(32 位十六进制);
|
-- 3. appSecret 每次执行脚本若不存在时会重新生成一个 UUID(32 位十六进制);
|
||||||
-- 已存在记录不会被修改,生产上如需重置请手动 UPDATE 或在应用管理界面点「重设密钥」。
|
-- 已存在记录不会被修改,生产上如需重置请手动 UPDATE 或在应用管理界面点「重设密钥」。
|
||||||
-- 4. 业务应用新增时 ApplicationInfoService 会按 MAX(app_key)+1 分配:
|
-- 4. 业务应用新增时 ApplicationInfoService 由 application_key_reservation 永久保留行分配
|
||||||
-- 因此本脚本写入 100000 后,下一条业务应用 appKey=100001,与预期一致。
|
-- (2026-10-26 起不再按 MAX(app_key)+1;本脚本写入 100000 后下一条业务应用自 100001 起,与预期一致)。
|
||||||
-- 5. AUTO_INCREMENT=100001 仅设置 application_info 表的主键 id(非 appKey),
|
-- 5. AUTO_INCREMENT=100001 仅设置 application_info 表的主键 id(非 appKey),
|
||||||
-- 使后续新建行的主键 id 也从 100001 起,与 appKey 段对齐,便于排查。
|
-- 使后续新建行的主键 id 也从 100001 起,与 appKey 段对齐,便于排查。
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|||||||
@@ -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 应用×学校单点开通判定):<br>
|
||||||
|
* 权威事实源=sys_group_app_rel(学校组单点、不沿祖先),与 list-by-school 同口径收敛成 boolean。<br>
|
||||||
|
* 钉四条边界:开通=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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String,Object> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);}
|
||||||
|
}
|
||||||
@@ -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<String> replay=ConcurrentHashMap.newKeySet();final String subject=Crypto.hash("fixture-subject");
|
||||||
|
final Set<String> 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<com.yunhai.platform.utils.model.BoaRoleInfo> 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> T as(String action,Callable<T> 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<T> 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<String,Object> 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<String,Object> 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<String,Object> 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<Callable<Void>> 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<Void> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);}
|
||||||
|
}
|
||||||
@@ -24,9 +24,15 @@ class ApplicationInfoAppCodeTest {
|
|||||||
repository = mock(ApplicationInfoRepository.class);
|
repository = mock(ApplicationInfoRepository.class);
|
||||||
ReflectionTestUtils.setField(service, "applicationInfoRepository", repository);
|
ReflectionTestUtils.setField(service, "applicationInfoRepository", repository);
|
||||||
ReflectionTestUtils.setField(service, "eventPublisher", mock(ApplicationEventPublisher.class));
|
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 = new ApplicationInfo();
|
||||||
entity.setId(1); entity.setAppCode("svc-hall");
|
entity.setId(1); entity.setAppCode("svc-hall");
|
||||||
when(repository.findById(1)).thenReturn(Optional.of(entity));
|
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));
|
when(repository.save(any(ApplicationInfo.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,5 +86,6 @@ class ApplicationInfoAppCodeTest {
|
|||||||
assertSame(entity, service.save(entity));
|
assertSame(entity, service.save(entity));
|
||||||
entity.setId(null);
|
entity.setId(null);
|
||||||
assertSame(entity, service.save(entity));
|
assertSame(entity, service.save(entity));
|
||||||
|
assertEquals(100019, entity.getAppKey());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ApplicationInfo> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user