diff --git a/docs/OPEN_PLATFORM_INTEGRATION.md b/docs/OPEN_PLATFORM_INTEGRATION.md index 583d5fa..b68671e 100644 --- a/docs/OPEN_PLATFORM_INTEGRATION.md +++ b/docs/OPEN_PLATFORM_INTEGRATION.md @@ -73,8 +73,11 @@ com.gxwebsoft.openplatform ├─ web/OpenPageResult.java 对外分页结构 {list,total,page,limit} ├─ constant/OpenScopes.java scope 常量 ├─ param/OpenOrderPageParam.java 入参白名单(刻意不含 tenantId) +├─ param/OpenUserPageParam.java 用户查询入参白名单 ├─ vo/OpenOrderVO.java 出参裁剪 + 手机号脱敏 -└─ controller/OpenOrderController.java /api/open/v1/order/page +├─ vo/OpenUserVO.java 出参裁剪 + 手机号/邮箱脱敏,不含密码字段 +├─ controller/OpenOrderController.java /api/open/v1/order/page +└─ controller/OpenUserController.java /api/open/v1/user/page ``` 既有文件改动: @@ -115,15 +118,18 @@ open-platform: ## 5. 接口契约 +### 5.1 订单列表 + `GET /api/open/v1/order/page` | 项 | 说明 | | --- | --- | | 认证 | `Authorization: Bearer ` | -| 权限 | scope 含 `order:read`,否则 403 | +| 权限 | scope 含 `shop:shopOrder:list`,否则 403 | | 数据范围 | 令牌 `tenant_id` 对应租户的订单,**不接受也不识别 `tenantId` 参数或请求头** | | 分页 | `page`(默认 1)、`limit`(默认 20,上限 100) | | 过滤 | `orderNo`(模糊)、`type`、`orderStatus`、`payStatus`、`payType`、`createTimeStart`、`createTimeEnd` | +| 时间格式 | `yyyy-MM-dd HH:mm:ss`,输出库中存储的挂钟时间,不做时区换算 | 响应沿用平台约定(**HTTP 状态码固定 200**,业务结果看 `code`;不返回 `error` 字段): @@ -155,9 +161,36 @@ open-platform: | code | message | 触发条件 | | --- | --- | --- | | 401 | 令牌缺失或无效 | 未带令牌、签名错误、已过期、`iss`/`aud` 不匹配 | -| 403 | 权限不足,请确认应用已获得该接口权限 | 令牌没有 `order:read` | +| 403 | 权限不足,请确认应用已获得该接口权限 | 令牌没有 `shop:shopOrder:list` | | 403 | 令牌缺少租户信息,无法确定数据范围 | 令牌没有 `tenant_id` | +### 5.2 用户列表 + +`GET /api/open/v1/user/page` + +| 项 | 说明 | +| --- | --- | +| 认证 | `Authorization: Bearer ` | +| 权限 | scope 含 `sys:user:list`,否则 403 | +| 数据范围 | 令牌 `tenant_id` 对应租户的用户 | +| 分页 | `page`(默认 1)、`limit`(默认 20,上限 100) | +| 过滤 | `username`(模糊)、`nickname`(模糊)、`type`、`status`、`createTimeStart`、`createTimeEnd` | + +出参只包含 `userId` / `userCode` / `username` / `nickname` / `realName` / `type` / `sex` / +`sexName` / `phone` / `email` / `emailVerified` / `organizationId` / `organizationName` / +`status` / `auditStatus` / `createTime`。 + +> **为什么必须用独立 VO**:`User` 实体对 `password`、`payPassword` 都没有 `@JsonIgnore`, +> 而 `UserMapper` 用的是 `SELECT a.*`。内部接口 `/api/system/user/page` 直接把实体返回, +> 响应里带着密码哈希;开放接口如果照抄这个写法就会把凭证交给第三方。 +> `OpenUserVOTest` 里有一条断言专门防止这种回归。 + +> **scope 命名说明**:`shop:shopOrder:list` 取自 `sys_menu.authority` 里已有的权限点 +> (菜单 157795「查询」、182274「项目订单」),与内部权限体系保持一致。 +> 但注意 `gxwebsoft_core` 库里**没有 `shop_*` 表**(只有 `sys_order` / `sys_order_goods`), +> 当前该 scope 守卫的是 `sys_order` 的数据。若后续要对外开放的是商城订单, +> 需要把接口指向商城所在的服务与库,而不是复用本 controller。 + ## 6. 随本次改动修复的既有问题 `TenantController.page`(`GET /api/system/tenant/page`)原先没有 `@PreAuthorize`, @@ -171,6 +204,10 @@ open-platform: > 同类风险可能还有其它「无 `@PreAuthorize` 的 GET 接口」。彻底收口需要先把 > `SecurityConfig` 里 GET 的 `/**` 白名单摘掉,再按真实流量补显式白名单, > 影响面较大,建议单独排期。 +> +> 已知同一族的两个问题(本次未动,因为属于内部接口行为变更): +> `GET /api/system/user/withoutAuth` 免登录且可返回用户实体(含密码哈希), +> `GET /api/system/user/page` 等接口返回的实体也带密码字段——两者都应该改为按需 VO。 ## 7. 如何新增一个开放接口 @@ -194,15 +231,28 @@ open-platform: 覆盖:`iss`/`aud`/`exp` 三条校验规则、租户只来自令牌(伪造 `tenantId` 请求头无效)、 令牌缺租户时拒绝、请求结束清理上下文、出参脱敏与字段裁剪。 -端到端(本地自建 JWKS,无需 base-api 凭证): +端到端脚本 `scripts/verify-open-api.sh`,一条命令跑完「换令牌 → 解令牌 → 调接口 → 负向用例」: -签发 `tenant_id` 分别为 A / B / 不存在的租户三枚令牌调用接口, -预期返回条数与数据库 `SELECT COUNT(*) FROM sys_order WHERE deleted=0 AND tenant_id=?` -完全一致,且伪造 `tenantId` 请求头不改变结果。 +```bash +# 真实链路(需要应用凭证) +OPEN_CLIENT_ID=xxx OPEN_CLIENT_SECRET=yyy ./scripts/verify-open-api.sh + +# 已有令牌时跳过换令牌 +OPEN_ACCESS_TOKEN=eyJ... BUSINESS_BASE_URL=http://127.0.0.1:8080 TENANT_EXPECT=6 ./scripts/verify-open-api.sh +``` + +判定标准: + +1. 令牌 `scope` 含 `shop:shopOrder:list`、含 `tenant_id`; +2. 订单列表 `code=0`,`total` 与 `SELECT COUNT(*) FROM sys_order WHERE deleted=0 AND tenant_id=?` 一致; +3. 不带令牌返回 401; +4. 伪造 `tenantId` 请求头不改变返回结果。 + +本地自建 JWKS(不依赖 base-api)的联调方式见第 4 节。 ## 9. 待办 -- [ ] `base-api` 的权限字典补充 `order:read` 等业务 scope,并在应用管理页可勾选; +- [ ] `base-api` 的权限字典补充 `shop:shopOrder:list` 等业务 scope,并在应用管理页可勾选; - [ ] 开放流量的限流与配额(当前 `open_app.rate_limit` 只作用于令牌签发); - [ ] 调用审计埋点(`client_id` / `tenant_id` / `scope` 全链路); - [ ] `SecurityConfig` 的 GET `/**` 白名单收口; diff --git a/scripts/verify-open-api.sh b/scripts/verify-open-api.sh new file mode 100755 index 0000000..2a1499e --- /dev/null +++ b/scripts/verify-open-api.sh @@ -0,0 +1,180 @@ +#!/bin/bash +# +# 开放平台接入验证脚本 +# +# 作用:验证「应用凭证 → 换取令牌 → 携带令牌调用业务接口 → 租户隔离与权限生效」整条链路。 +# +# 用法一(真实链路,需要应用凭证): +# OPEN_CLIENT_ID=xxx OPEN_CLIENT_SECRET=yyy ./scripts/verify-open-api.sh +# +# 用法二(已有令牌,跳过换令牌,用于本地/联调): +# OPEN_ACCESS_TOKEN=eyJ... BUSINESS_BASE_URL=http://127.0.0.1:8080 ./scripts/verify-open-api.sh +# +# 可选环境变量: +# OPEN_BASE_URL 开放平台地址,默认 https://base-api.websoft.top/api +# BUSINESS_BASE_URL 业务服务地址,默认 http://127.0.0.1:8000 +# OPEN_SCOPE 期望的权限标识,默认 shop:shopOrder:list +# OPEN_PATH 被测接口路径,默认 /api/open/v1/order/page +# 测用户列表:OPEN_PATH=/api/open/v1/user/page OPEN_SCOPE=sys:user:list +# TENANT_EXPECT 期望命中本租户的记录数,留空则不校验数量(需与数据库核对后填写) +# + +set -euo pipefail + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' + +OPEN_BASE_URL="${OPEN_BASE_URL:-https://base-api.websoft.top/api}" +BUSINESS_BASE_URL="${BUSINESS_BASE_URL:-http://127.0.0.1:8000}" +OPEN_SCOPE="${OPEN_SCOPE:-shop:shopOrder:list}" +OPEN_PATH="${OPEN_PATH:-/api/open/v1/order/page}" + +ok() { echo -e "${GREEN}✔${NC} $1"; } +bad() { echo -e "${RED}✘${NC} $1"; } +warn() { echo -e "${YELLOW}!${NC} $1"; } +step() { echo -e "\n${BLUE}== $1 ==${NC}"; } + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || { bad "缺少命令:$1"; exit 1; } +} +need_cmd curl +need_cmd python3 + +# 只解包 ApiResult,不校验业务语义 +json_field() { + python3 -c " +import json,sys +try: + data = json.load(sys.stdin) +except Exception: + print('') + sys.exit(0) +cur = data +for part in sys.argv[1].split('.'): + if isinstance(cur, dict): + cur = cur.get(part) + else: + cur = None + break +print('' if cur is None else cur) +" "$1" +} + +decode_jwt_payload() { + python3 -c " +import base64, json, sys +token = sys.argv[1].strip() +parts = token.split('.') +if len(parts) < 2: + print('{}') + sys.exit(0) +payload = parts[1].replace('-', '+').replace('_', '/') +payload += '=' * ((4 - len(payload) % 4) % 4) +try: + print(json.dumps(json.loads(base64.b64decode(payload)), ensure_ascii=False, indent=2)) +except Exception as e: + print('{}') +" "$1" +} + +step "0. 目标地址" +echo " 开放平台:$OPEN_BASE_URL" +echo " 业务服务:$BUSINESS_BASE_URL" +echo " 期望权限:$OPEN_SCOPE" +echo " 接口路径:$OPEN_PATH" + +step "1. 获取 access token" +if [ -n "${OPEN_ACCESS_TOKEN:-}" ]; then + ACCESS_TOKEN="$OPEN_ACCESS_TOKEN" + ok "使用已提供的 OPEN_ACCESS_TOKEN" +else + if [ -z "${OPEN_CLIENT_ID:-}" ] || [ -z "${OPEN_CLIENT_SECRET:-}" ]; then + bad "请设置 OPEN_CLIENT_ID / OPEN_CLIENT_SECRET,或直接提供 OPEN_ACCESS_TOKEN" + exit 1 + fi + TOKEN_RESPONSE=$(curl -s -m 20 -X POST "$OPEN_BASE_URL/v1/oauth/token" \ + -H 'Content-Type: application/json' \ + -d "{\"grantType\":\"client_credentials\",\"clientId\":\"$OPEN_CLIENT_ID\",\"clientSecret\":\"$OPEN_CLIENT_SECRET\"}") + echo "$TOKEN_RESPONSE" | python3 -m json.tool 2>/dev/null | head -20 || echo "$TOKEN_RESPONSE" + CODE=$(echo "$TOKEN_RESPONSE" | json_field code) + if [ "$CODE" != "0" ]; then + bad "换取令牌失败:$(echo "$TOKEN_RESPONSE" | json_field message)" + exit 1 + fi + ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | json_field data.accessToken) + ok "换取令牌成功" +fi + +if [ -z "$ACCESS_TOKEN" ]; then + bad "accessToken 为空" + exit 1 +fi + +step "2. 解析令牌声明(仅本地解码,不代表已验签)" +decode_jwt_payload "$ACCESS_TOKEN" + +CLAIMS=$(decode_jwt_payload "$ACCESS_TOKEN") +TOKEN_SCOPE=$(echo "$CLAIMS" | json_field scope) +TOKEN_TENANT=$(echo "$CLAIMS" | json_field tenant_id) + +if echo " $TOKEN_SCOPE " | grep -q " $OPEN_SCOPE "; then + ok "令牌包含权限 $OPEN_SCOPE" +else + bad "令牌不含 $OPEN_SCOPE,当前 scope:${TOKEN_SCOPE:-(空)}" + warn "请确认:1) base-api 权限字典已登记该 scope;2) 已在应用管理里勾选;3) 重新换过令牌" + exit 1 +fi + +if [ -n "$TOKEN_TENANT" ] && [ "$TOKEN_TENANT" != "None" ]; then + ok "令牌携带租户 tenant_id=$TOKEN_TENANT(业务侧据此隔离数据)" +else + bad "令牌没有 tenant_id,业务接口会返回 403" + exit 1 +fi + +step "3. 携带令牌调用开放接口" +RESPONSE=$(curl -s -m 20 -H "Authorization: Bearer $ACCESS_TOKEN" \ + "$BUSINESS_BASE_URL$OPEN_PATH?page=1&limit=5") +echo "$RESPONSE" | python3 -m json.tool 2>/dev/null | head -40 || echo "$RESPONSE" + +CODE=$(echo "$RESPONSE" | json_field code) +if [ "$CODE" != "0" ]; then + bad "调用失败:code=$CODE message=$(echo "$RESPONSE" | json_field message)" + warn "404 说明业务服务未部署该开放接口;403 说明权限或租户不满足" + exit 1 +fi +ok "接口调用成功,total=$(echo "$RESPONSE" | json_field data.total)" + +if [ -n "${TENANT_EXPECT:-}" ]; then + TOTAL=$(echo "$RESPONSE" | json_field data.total) + if [ "$TOTAL" = "$TENANT_EXPECT" ]; then + ok "条数与预期一致($TOTAL)" + else + bad "条数不符:接口返回 $TOTAL,预期 $TENANT_EXPECT" + exit 1 + fi +fi + +step "4. 负向用例" + +NO_TOKEN=$(curl -s -m 20 "$BUSINESS_BASE_URL$OPEN_PATH?page=1&limit=1") +NO_TOKEN_CODE=$(echo "$NO_TOKEN" | json_field code) +if [ "$NO_TOKEN_CODE" = "401" ]; then + ok "未携带令牌被拒绝(code=401)" +else + bad "未携带令牌竟然返回 code=$NO_TOKEN_CODE,开放接口可能未走独立安全链" +fi + +if [ -n "$TOKEN_TENANT" ] && [ "$TOKEN_TENANT" != "None" ]; then + SPOOF=$(curl -s -m 20 -H "Authorization: Bearer $ACCESS_TOKEN" -H "tenantId: 1" \ + "$BUSINESS_BASE_URL$OPEN_PATH?page=1&limit=5") + SPOOF_TOTAL=$(echo "$SPOOF" | json_field data.total) + BASE_TOTAL=$(echo "$RESPONSE" | json_field data.total) + if [ "$SPOOF_TOTAL" = "$BASE_TOTAL" ]; then + ok "伪造 tenantId 请求头无效(仍返回本租户数据)" + else + bad "伪造 tenantId 改变了结果($BASE_TOTAL -> $SPOOF_TOTAL),存在越权风险" + fi +fi + +echo +ok "验证完成" diff --git a/src/main/java/com/gxwebsoft/openplatform/constant/OpenScopes.java b/src/main/java/com/gxwebsoft/openplatform/constant/OpenScopes.java index df7913c..f503a61 100644 --- a/src/main/java/com/gxwebsoft/openplatform/constant/OpenScopes.java +++ b/src/main/java/com/gxwebsoft/openplatform/constant/OpenScopes.java @@ -9,8 +9,20 @@ package com.gxwebsoft.openplatform.constant; */ public final class OpenScopes { - /** 查询订单 */ - public static final String ORDER_READ = "order:read"; + /** + * 查询项目订单。 + * + *

与 sys_menu.authority 里的既有权限点同名(菜单 157795「查询」、182274「项目订单」), + * 便于与内部权限体系对照。

+ */ + public static final String SHOP_ORDER_LIST = "shop:shopOrder:list"; + + /** + * 查询用户列表。 + * + *

与 sys_menu.authority 里的既有权限点同名(菜单 3561 等「查询」)。

+ */ + public static final String SYS_USER_LIST = "sys:user:list"; private OpenScopes() { } diff --git a/src/main/java/com/gxwebsoft/openplatform/controller/OpenOrderController.java b/src/main/java/com/gxwebsoft/openplatform/controller/OpenOrderController.java index a4b2de1..2bc393f 100644 --- a/src/main/java/com/gxwebsoft/openplatform/controller/OpenOrderController.java +++ b/src/main/java/com/gxwebsoft/openplatform/controller/OpenOrderController.java @@ -50,7 +50,7 @@ public class OpenOrderController { @Resource private OpenPlatformProperties properties; - @PreAuthorize("hasAuthority('SCOPE_" + OpenScopes.ORDER_READ + "')") + @PreAuthorize("hasAuthority('SCOPE_" + OpenScopes.SHOP_ORDER_LIST + "')") @Operation(summary = "分页查询本租户订单") @GetMapping("/page") public ApiResult> page(@AuthenticationPrincipal Jwt jwt, diff --git a/src/main/java/com/gxwebsoft/openplatform/controller/OpenUserController.java b/src/main/java/com/gxwebsoft/openplatform/controller/OpenUserController.java new file mode 100644 index 0000000..0c86b25 --- /dev/null +++ b/src/main/java/com/gxwebsoft/openplatform/controller/OpenUserController.java @@ -0,0 +1,82 @@ +package com.gxwebsoft.openplatform.controller; + +import cn.hutool.core.util.StrUtil; +import com.gxwebsoft.common.core.Constants; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.common.system.param.UserParam; +import com.gxwebsoft.common.system.service.UserService; +import com.gxwebsoft.openplatform.config.OpenPlatformProperties; +import com.gxwebsoft.openplatform.constant.OpenScopes; +import com.gxwebsoft.openplatform.context.OpenTenantContext; +import com.gxwebsoft.openplatform.param.OpenUserPageParam; +import com.gxwebsoft.openplatform.vo.OpenUserVO; +import com.gxwebsoft.openplatform.web.OpenPageResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import java.util.List; + +/** + * 开放平台 · 用户接口。 + * + *

{@code sys_user} 不在 {@code MybatisPlusConfig} 的 {@code ignoreTable} 白名单里, + * 多租户插件会自动加上 {@code tenant_id} 条件;这里仍显式设置一次, + * 两个来源都指向令牌里的租户({@code OpenTenantContext}),属于双保险。

+ * + * @author WebSoft + */ +@Tag(name = "开放接口-用户") +@RestController +@ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true) +@RequestMapping("/api/open/v1/user") +public class OpenUserController { + + @Resource + private UserService userService; + @Resource + private OpenPlatformProperties properties; + + @PreAuthorize("hasAuthority('SCOPE_" + OpenScopes.SYS_USER_LIST + "')") + @Operation(summary = "分页查询本租户用户") + @GetMapping("/page") + public ApiResult> page(OpenUserPageParam query) { + // 租户只认令牌,拦截器已校验 tenant_id 存在 + Integer tenantId = OpenTenantContext.getTenantId(); + + UserParam param = new UserParam(); + param.setPage(normalizePage(query.getPage())); + param.setLimit(normalizeLimit(query.getLimit())); + param.setTenantId(tenantId); + param.setUsername(StrUtil.trimToNull(query.getUsername())); + param.setNickname(StrUtil.trimToNull(query.getNickname())); + param.setType(query.getType()); + param.setStatus(query.getStatus()); + param.setCreateTimeStart(StrUtil.trimToNull(query.getCreateTimeStart())); + param.setCreateTimeEnd(StrUtil.trimToNull(query.getCreateTimeEnd())); + + PageResult result = userService.pageRel(param); + List list = OpenUserVO.from(result.getList(), properties.isMaskSensitive()); + return new ApiResult<>(Constants.RESULT_OK_CODE, Constants.RESULT_OK_MSG, + new OpenPageResult<>(list, result.getCount(), param.getPage(), param.getLimit())); + } + + private Long normalizePage(Long page) { + return (page == null || page < 1L) ? 1L : page; + } + + private Long normalizeLimit(Long limit) { + if (limit == null || limit < 1L) { + return 20L; + } + return Math.min(limit, OpenUserPageParam.MAX_LIMIT); + } + +} diff --git a/src/main/java/com/gxwebsoft/openplatform/param/OpenUserPageParam.java b/src/main/java/com/gxwebsoft/openplatform/param/OpenUserPageParam.java new file mode 100644 index 0000000..cdbbf8f --- /dev/null +++ b/src/main/java/com/gxwebsoft/openplatform/param/OpenUserPageParam.java @@ -0,0 +1,47 @@ +package com.gxwebsoft.openplatform.param; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * 开放接口用户分页查询入参。 + * + *

白名单字段,刻意不含 {@code password}、{@code tenantId} 等内部可构造字段。

+ * + * @author WebSoft + */ +@Data +@Schema(name = "OpenUserPageParam", description = "开放接口用户分页查询参数") +public class OpenUserPageParam implements Serializable { + private static final long serialVersionUID = 1L; + + /** 每页最大条数 */ + public static final long MAX_LIMIT = 100L; + + @Schema(description = "页码,从 1 开始", example = "1") + private Long page = 1L; + + @Schema(description = "每页数量,最大 100", example = "20") + private Long limit = 20L; + + @Schema(description = "账号,模糊匹配") + private String username; + + @Schema(description = "昵称,模糊匹配") + private String nickname; + + @Schema(description = "用户类型") + private Integer type; + + @Schema(description = "用户状态") + private Integer status; + + @Schema(description = "注册时间起始,格式 yyyy-MM-dd HH:mm:ss") + private String createTimeStart; + + @Schema(description = "注册时间结束,格式 yyyy-MM-dd HH:mm:ss") + private String createTimeEnd; + +} diff --git a/src/main/java/com/gxwebsoft/openplatform/vo/OpenOrderVO.java b/src/main/java/com/gxwebsoft/openplatform/vo/OpenOrderVO.java index eb573b8..bcbec6e 100644 --- a/src/main/java/com/gxwebsoft/openplatform/vo/OpenOrderVO.java +++ b/src/main/java/com/gxwebsoft/openplatform/vo/OpenOrderVO.java @@ -1,5 +1,6 @@ package com.gxwebsoft.openplatform.vo; +import com.fasterxml.jackson.annotation.JsonFormat; import com.gxwebsoft.common.system.entity.Order; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -71,13 +72,19 @@ public class OpenOrderVO implements Serializable { @Schema(description = "备注") private String comments; - @Schema(description = "支付时间") + // 与 Order 实体的序列化保持一致:输出库中存储的挂钟时间,不做时区换算。 + // 注意 JacksonConfig 里的 @Primary ObjectMapper 是新建的,不加载 spring.jackson.* 配置, + // 不显式声明格式会退化成 ISO-8601(2024-11-12T21:03:31.000+00:00)。 + @Schema(description = "支付时间,格式 yyyy-MM-dd HH:mm:ss") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date payTime; - @Schema(description = "退款时间") + @Schema(description = "退款时间,格式 yyyy-MM-dd HH:mm:ss") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date refundTime; - @Schema(description = "下单时间") + @Schema(description = "下单时间,格式 yyyy-MM-dd HH:mm:ss") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date createTime; /** diff --git a/src/main/java/com/gxwebsoft/openplatform/vo/OpenUserVO.java b/src/main/java/com/gxwebsoft/openplatform/vo/OpenUserVO.java new file mode 100644 index 0000000..1147d18 --- /dev/null +++ b/src/main/java/com/gxwebsoft/openplatform/vo/OpenUserVO.java @@ -0,0 +1,131 @@ +package com.gxwebsoft.openplatform.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.gxwebsoft.common.system.entity.User; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 开放接口的用户出参。 + * + *

安全要点:{@code User} 实体没有对 {@code password} 做 {@code @JsonIgnore}, + * 且 UserMapper 用的是 {@code SELECT a.*},直接把实体返回给第三方会泄露密码哈希与支付密码。 + * 这里只挑出必要字段,密码类字段一律不出现。

+ * + * @author WebSoft + */ +@Data +@Schema(name = "OpenUser", description = "开放接口用户") +public class OpenUserVO implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "用户id") + private Integer userId; + + @Schema(description = "用户编码") + private String userCode; + + @Schema(description = "账号") + private String username; + + @Schema(description = "昵称") + private String nickname; + + @Schema(description = "真实姓名") + private String realName; + + @Schema(description = "用户类型") + private Integer type; + + @Schema(description = "性别字典值") + private String sex; + + @Schema(description = "性别名称") + private String sexName; + + @Schema(description = "手机号(按配置脱敏)") + private String phone; + + @Schema(description = "邮箱(按配置脱敏)") + private String email; + + @Schema(description = "邮箱是否验证,0否 1是") + private Integer emailVerified; + + @Schema(description = "机构id") + private Integer organizationId; + + @Schema(description = "机构名称") + private String organizationName; + + @Schema(description = "状态") + private Integer status; + + @Schema(description = "审核状态:0待审核 1已通过 2已拒绝") + private Integer auditStatus; + + @Schema(description = "注册时间,格式 yyyy-MM-dd HH:mm:ss") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createTime; + + /** + * 实体转出参。 + * + * @param users 用户列表 + * @param maskSensitive 是否对手机号、邮箱脱敏 + */ + public static List from(List users, boolean maskSensitive) { + if (users == null || users.isEmpty()) { + return Collections.emptyList(); + } + List list = new ArrayList<>(users.size()); + for (User user : users) { + OpenUserVO vo = new OpenUserVO(); + vo.setUserId(user.getUserId()); + vo.setUserCode(user.getUserCode()); + vo.setUsername(user.getUsername()); + vo.setNickname(user.getNickname()); + vo.setRealName(user.getRealName()); + vo.setType(user.getType()); + vo.setSex(user.getSex()); + vo.setSexName(user.getSexName()); + vo.setPhone(maskSensitive ? maskPhone(user.getPhone()) : user.getPhone()); + vo.setEmail(maskSensitive ? maskEmail(user.getEmail()) : user.getEmail()); + vo.setEmailVerified(user.getEmailVerified()); + vo.setOrganizationId(user.getOrganizationId()); + vo.setOrganizationName(user.getOrganizationName()); + vo.setStatus(user.getStatus()); + vo.setAuditStatus(user.getAuditStatus()); + vo.setCreateTime(user.getCreateTime()); + list.add(vo); + } + return list; + } + + private static String maskPhone(String phone) { + if (phone == null || phone.length() < 7) { + return phone; + } + return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4); + } + + private static String maskEmail(String email) { + if (email == null) { + return null; + } + int at = email.indexOf('@'); + if (at <= 0) { + // 不是正常的邮箱格式,原样返回,不臆造 + return email; + } + // 统一保留局部名首字符与完整域名:zhangsan@example.com -> z***@example.com + return email.charAt(0) + "***" + email.substring(at); + } + +} diff --git a/src/test/java/com/gxwebsoft/openplatform/OpenPlatformJwtValidationTest.java b/src/test/java/com/gxwebsoft/openplatform/OpenPlatformJwtValidationTest.java index a8bbcfa..47fd0b5 100644 --- a/src/test/java/com/gxwebsoft/openplatform/OpenPlatformJwtValidationTest.java +++ b/src/test/java/com/gxwebsoft/openplatform/OpenPlatformJwtValidationTest.java @@ -70,7 +70,7 @@ class OpenPlatformJwtValidationTest { .subject("demo-app") .claim("client_id", "demo-app") .claim("tenant_id", 1001) - .claim("scope", "order:read") + .claim("scope", "shop:shopOrder:list") .issueTime(Date.from(Instant.now().minusSeconds(30))) .expirationTime(Date.from(expiresAt)) .build(); @@ -89,7 +89,7 @@ class OpenPlatformJwtValidationTest { assertNotNull(jwt); assertEquals("demo-app", jwt.getClaimAsString("client_id")); assertEquals(1001, ((Number) jwt.getClaim("tenant_id")).intValue()); - assertEquals("order:read", jwt.getClaimAsString("scope")); + assertEquals("shop:shopOrder:list", jwt.getClaimAsString("scope")); } @Test diff --git a/src/test/java/com/gxwebsoft/openplatform/OpenTenantBindingTest.java b/src/test/java/com/gxwebsoft/openplatform/OpenTenantBindingTest.java index 8fc93bb..1c2217e 100644 --- a/src/test/java/com/gxwebsoft/openplatform/OpenTenantBindingTest.java +++ b/src/test/java/com/gxwebsoft/openplatform/OpenTenantBindingTest.java @@ -42,7 +42,7 @@ class OpenTenantBindingTest { Jwt.Builder builder = Jwt.withTokenValue("test-token") .header("alg", "RS256") .claim("client_id", "demo-app") - .claim("scope", "order:read order:write"); + .claim("scope", "shop:shopOrder:list shop:shopOrder:save"); if (tenantId != null) { builder.claim("tenant_id", tenantId); } @@ -62,7 +62,8 @@ class OpenTenantBindingTest { assertEquals(1001, OpenTenantContext.getTenantId()); assertEquals("demo-app", OpenTenantContext.get().getClientId()); - assertEquals(List.of("order:read", "order:write"), OpenTenantContext.get().getScopes()); + assertEquals(List.of("shop:shopOrder:list", "shop:shopOrder:save"), + OpenTenantContext.get().getScopes()); } @Test diff --git a/src/test/java/com/gxwebsoft/openplatform/OpenUserVOTest.java b/src/test/java/com/gxwebsoft/openplatform/OpenUserVOTest.java new file mode 100644 index 0000000..f973bda --- /dev/null +++ b/src/test/java/com/gxwebsoft/openplatform/OpenUserVOTest.java @@ -0,0 +1,99 @@ +package com.gxwebsoft.openplatform; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.openplatform.vo.OpenUserVO; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 开放接口用户出参测试。 + * + *

重点是不能泄露密码:User 实体没有 @JsonIgnore,UserMapper 又用 SELECT a.*, + * 所以这里直接序列化结果做断言,防止以后有人图省事把实体返回出去。

+ * + * @author WebSoft + */ +class OpenUserVOTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private User user() { + User user = new User(); + user.setUserId(1); + user.setUsername("zhangsan"); + user.setNickname("张三"); + user.setPhone("13800000009"); + user.setEmail("zhangsan@example.com"); + // 敏感字段:绝不能出现在开放接口响应里 + user.setPassword("$2a$10$abcdefghijklmnopqrstuv"); + user.setPayPassword("$2a$10$paypaypaypaypaypaypayp"); + user.setIdCard("450102199001011234"); + return user; + } + + @Test + @DisplayName("序列化结果里不含密码、支付密码、身份证号") + void neverExposesCredentials() throws Exception { + List list = OpenUserVO.from(List.of(user()), true); + + String json = MAPPER.writeValueAsString(list); + + assertFalse(json.contains("password"), "响应不应包含 password / payPassword 字段"); + assertFalse(json.contains("payPassword"), "响应不应包含 payPassword 字段"); + assertFalse(json.contains("$2a$10$"), "响应不应包含密码哈希"); + assertFalse(json.contains("idCard"), "响应不应包含身份证号字段"); + assertTrue(json.contains("zhangsan")); + } + + @Test + @DisplayName("手机号与邮箱按开关脱敏") + void masksContactInfo() { + OpenUserVO masked = OpenUserVO.from(List.of(user()), true).get(0); + assertEquals("138****0009", masked.getPhone()); + assertEquals("z***@example.com", masked.getEmail()); + + OpenUserVO plain = OpenUserVO.from(List.of(user()), false).get(0); + assertEquals("13800000009", plain.getPhone()); + assertEquals("zhangsan@example.com", plain.getEmail()); + } + + @Test + @DisplayName("异常联系方式的兜底处理") + void handlesUnusualContactInfo() { + User user = user(); + + user.setPhone("123"); + user.setEmail("a@b.com"); + OpenUserVO vo = OpenUserVO.from(List.of(user), true).get(0); + assertEquals("123", vo.getPhone()); + assertEquals("a***@b.com", vo.getEmail()); + + // 非法格式不臆造,原样返回 + user.setEmail("@example.com"); + assertEquals("@example.com", OpenUserVO.from(List.of(user), true).get(0).getEmail()); + user.setEmail("no-at-sign"); + assertEquals("no-at-sign", OpenUserVO.from(List.of(user), true).get(0).getEmail()); + + user.setPhone(null); + user.setEmail(null); + vo = OpenUserVO.from(List.of(user), true).get(0); + assertNull(vo.getPhone()); + assertNull(vo.getEmail()); + } + + @Test + @DisplayName("空列表返回空集合") + void handlesEmptyList() { + assertTrue(OpenUserVO.from(null, true).isEmpty()); + assertTrue(OpenUserVO.from(List.of(), true).isEmpty()); + } + +}