[java]如何实现一个标准的开放应用服务架构

一、背景

基于 Java17 SpringBoot JPA 我们已经完成了应用的各种业务服务的开发,突然有一天接到了新的需求:为了提高系统的开放、安全、合作等,我们的系统需要提供第三方系统的无缝对接的标准开放API

于是我们着手设计了这套开放应用的服务标准架构。

二、需求分析

首先,我们需要分析需要支持的一些服务:

  • 开放应用的管理

需要支持应用创建、删除、密钥的重置等

  • 开放应用的安全

开放应用需要支持配置数据加密方式、签名、防重放、请求时间限制、IP白名单等安全机制

  • 需要支持标准的API接入

需要支持标准的数据结构、签名校验、错误代码处理、数据校验等

需求分析到这里也基本能支持业务需求了,那么接下来开始着手设计。

这是整个时序图:

用户开放平台第三方系统登录(账号/密码)验证账号密码登录成功创建应用(名称/加密方式/回调地址/IP白名单)应用创建成功(appKey/appSecret)提供对接参数(appKey/appSecret)加签、加密请求API验签、解密返回数据用户开放平台第三方系统

三、开放应用

开放应用的定义,是由用户自行创建的,用户需要在开放服务中创建自己的应用,然后设定应用的一些基础配置和一些安全策略,然后获得 appKey appSecret 等对接参数后即可提供给第三方系统进行标准的API对接。

创建应用前,我们需要先定义一些应用的基础属性:

3.1 应用加密方式

java

@AllArgsConstructor
@Getter
@Description("开放应用加密方式")
public enum OpenArithmeticType implements IDictionary {
    /**
     * <h2>{@code AES} 算法</h2>
     */
    AES(1, "AES"),

    /**
     * <h2>{@code RSA} 算法</h2>
     */
    RSA(2, "RSA"),

    /**
     * <h2>不加密</h2>
     */
    NO(3, "NO");

    private final int key;
    private final String label;
}

3.2 开放应用的实体

如下是 OpenAppEntity 开放应用的实体设计

java

@EqualsAndHashCode(callSuper = true)
@Accessors(chain = true)
@Entity
@Data
@DynamicInsert
@DynamicUpdate
@Table(name = "open_app")
@Description("开放应用")
public class OpenAppEntity extends BaseEntity<OpenAppEntity> implements IOpenAppAction, IOpenApp {
    @Description("应用Key")
    @Column(columnDefinition = "varchar(255) default '' comment 'AppKey'", unique = true)
    private String appKey;

    @Description("应用密钥")
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Column(columnDefinition = "varchar(255) default '' comment 'AppSecret'")
    @NotBlank(groups = {WhenCode2AccessToken.class})
    @Exclude(filters = {WhenGetDetail.class})
    private String appSecret;

    @Description("应用名称")
    @Column(columnDefinition = "varchar(255) default '' comment '应用名称'")
    @NotBlank(groups = {WhenUpdate.class, WhenAdd.class}, message = "应用名称不能为空")
    private String appName;

    @Description("加密算法")
    @Dictionary(value = OpenArithmeticType.class, groups = {WhenAdd.class, WhenUpdate.class})
    @Column(columnDefinition = "tinyint UNSIGNED default 1 comment '加密算法'")
    @Search(Search.Mode.EQUALS)
    private Integer arithmetic;

    @Description("IP白名单")
    @Column(columnDefinition = "text comment 'IP白名单'")
    private String ipWhiteList;

    @Description("公钥")
    @ReadOnly
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Column(columnDefinition = "text comment '公钥'")
    private String publicKey;

    @Description("私钥")
    @ReadOnly
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Column(columnDefinition = "text comment '私钥'")
    private String privateKey;

    @Description("应用所有者")
    @ManyToOne
    @Search(Search.Mode.JOIN)
    @ReadOnly
    private UserEntity owner;

    @Description("应用地址")
    @Column(columnDefinition = "varchar(255) default '' comment '应用地址'")
    @NotBlank(groups = {WhenAdd.class, WhenUpdate.class}, message = "应用地址必须填写")
    private String url;

    @Description("临时码")
    @NotBlank(groups = {WhenCode2AccessToken.class}, message = "Code不能为空")
    @Transient
    private String code;

    @Description("Cookie")
    @Transient
    private String cookie;
}

3.3 开放应用的控制器

接下来,我们需要在控制器中完成一些逻辑,比如创建、删除、禁用启用、修改等:

java

@ApiController("open/app")
@Description("开放应用")
public class OpenAppController extends BaseController<OpenAppEntity, OpenAppService, OpenAppRepository> implements IOpenAppAction {
    @Autowired
    private UserService userService;

    @Description("通过AppKey获取应用信息")
    @PostMapping("getByAppKey")
    @Permission(login = false)
    @Filter(RootEntity.WhenGetDetail.class)
    public Json getByAppKey(@RequestBody @Validated(WhenGetByAppKey.class) OpenAppEntity openApp) {
        openApp = service.getByAppKey(openApp.getAppKey());
        ServiceError.DATA_NOT_FOUND.whenNull(openApp, "没有查到指定AppKey的应用");
        return Json.data(openApp);
    }

    @Override
    public Json add(@RequestBody @Validated(WhenAdd.class) @NotNull OpenAppEntity openApp) {
        openApp.setOwner(userService.get(getCurrentUserId()));
        openApp = service.get(service.add(openApp));
        return Json.data(String.format("应用名称: %s\n\nAppKey:\n%s\n\nAppSecret:\n%s\n\n公钥:\n%s", openApp.getAppName(), openApp.getAppKey(), openApp.getAppSecret(), openApp.getPublicKey()));
    }

    @Override
    protected void afterAdd(long id, OpenAppEntity source) {
        super.afterAdd(id, source);
    }

    @Description("重置密钥")
    @PostMapping("resetSecret")
    public Json resetSecret(@RequestBody @Validated(WhenIdRequired.class) OpenAppEntity openApp) {
        OpenAppEntity exist = service.get(openApp.getId());
        String appSecret = Base64.getEncoder().encodeToString(RandomUtil.randomBytes());
        exist.setAppSecret(appSecret);
        service.update(exist);
        return Json.data(appSecret);
    }

    @Description("重置密钥对")
    @PostMapping("resetKeyPair")
    public Json resetKeyPair(@RequestBody @Validated(WhenIdRequired.class) OpenAppEntity openApp) {
        OpenAppEntity exist = service.get(openApp.getId());
        service.resetKeyPare(exist);
        service.update(exist);
        return Json.data(exist.getPublicKey());
    }

    @Override
    protected void beforeDelete(long id) {
        OpenAppEntity openApp = service.get(id);
        ServiceError.FORBIDDEN_DELETE.whenNotEquals(openApp.getOwner().getId(), getCurrentUserId(), "你无权删除该应用");
    }

    @Override
    protected void beforeDisable(long id) {
        OpenAppEntity openApp = service.get(id);
        ServiceError.FORBIDDEN_DELETE.whenNotEquals(openApp.getOwner().getId(), getCurrentUserId(), "你无权禁用该应用");
    }

    @Override
    protected void beforeEnable(long id) {
        OpenAppEntity openApp = service.get(id);
        ServiceError.FORBIDDEN_DELETE.whenNotEquals(openApp.getOwner().getId(), getCurrentUserId(), "你无权启用该应用");
    }

    @Override
    protected QueryListRequest<OpenAppEntity> beforeGetList(@NotNull QueryListRequest<OpenAppEntity> queryListRequest) {
        queryListRequest.setFilter(queryListRequest.getFilter().setOwner(userService.get(getCurrentUserId())));
        return queryListRequest;
    }

    @Override
    protected QueryPageRequest<OpenAppEntity> beforeGetPage(@NotNull QueryPageRequest<OpenAppEntity> queryPageRequest) {
        queryPageRequest.setFilter(queryPageRequest.getFilter().setOwner(userService.get(getCurrentUserId())));
        return queryPageRequest;
    }

    @Override
    protected OpenAppEntity beforeAdd(@NotNull OpenAppEntity openApp) {
        openApp.setOwner(userService.get(getCurrentUserId()));
        return openApp.setAppKey(null).setAppSecret(null).setPublicKey(null).setPrivateKey(null);
    }

    @Override
    protected OpenAppEntity beforeUpdate(@NotNull OpenAppEntity openApp) {
        openApp.setOwner(userService.get(getCurrentUserId()));
        return openApp.setAppKey(null).setAppSecret(null).setPublicKey(null).setPrivateKey(null);
    }
}

3.4 开放应用的Service

Service中也需要提供这些对应的服务:

java

@Service
public class OpenAppService extends BaseService<OpenAppEntity, OpenAppRepository> implements IOpenAppService {
    /**
     * <h2>通过AppKey获取一个应用</h2>
     *
     * @param appKey AppKey
     * @return 应用
     */
    @Override
    public OpenAppEntity getByAppKey(String appKey) {
        return repository.getByAppKey(appKey);
    }

    @Override
    protected @NotNull OpenAppEntity beforeAdd(@NotNull OpenAppEntity openApp) {
        openApp.setAppKey(createAppKey());
        openApp.setAppSecret(Base64.getEncoder().encodeToString(RandomUtil.randomBytes()));
        resetKeyPare(openApp);
        return openApp;
    }

    /**
     * <h2>重置密钥对</h2>
     *
     * @param openApp 应用
     */
    public final void resetKeyPare(@NotNull OpenAppEntity openApp) {
        try {
            KeyPair keyPair = RsaUtil.generateKeyPair();
            openApp.setPrivateKey(RsaUtil.convertPrivateKeyToPem(keyPair.getPrivate()));
            openApp.setPublicKey(RsaUtil.convertPublicKeyToPem(keyPair.getPublic()));
        } catch (NoSuchAlgorithmException e) {
            throw new ServiceException(e);
        }
    }

    /**
     * <h2>创建AppKey</h2>
     *
     * @return AppKey
     */
    private String createAppKey() {
        String appKey = RandomUtil.randomString();
        OpenAppEntity openApp = getByAppKey(appKey);
        if (Objects.isNull(openApp)) {
            return appKey;
        }
        return createAppKey();
    }
}

四、开放API

4.1 OpenApi 注解

提供开放API,我们提供了一个 @OpenApi 的注解来标记是 OpenApi,走单独的逻辑来做权限验证等。

java

@Target({ElementType.FIELD, ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface OpenApi {
}

4.2 OpenApi 切面

我们使用了切面来完成数据的预处理(验签、解密、权限)以及响应数据的加密等。

java

@Aspect
@Component
public class OpenApiAspect<S extends IOpenAppService, LS extends IOpenLogService> {
    @Autowired(required = false)
    private S openAppService;

    @Autowired(required = false)
    private LS openLogService;

    @SuppressWarnings("EmptyMethod")
    @Pointcut("@annotation(cn.hamm.airpower.open.OpenApi)")
    public void pointCut() {

    }

    /**
     * <h2>{@code OpenApi}</h2>
     */
    @Around("pointCut()")
    public Object openApi(@NotNull ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        Object[] args = proceedingJoinPoint.getArgs();
        if (args.length != 1) {
            throw new ServiceException("OpenApi必须接收一个参数");
        }
        if (!(args[0] instanceof OpenRequest openRequest)) {
            throw new ServiceException("OpenApi必须接收一个OpenRequest参数");
        }
        Signature signature = proceedingJoinPoint.getSignature();
        MethodSignature methodSignature = (MethodSignature) signature;
        Method method = methodSignature.getMethod();
        OpenApi openApi = method.getAnnotation(OpenApi.class);
        ServiceError.API_SERVICE_UNSUPPORTED.whenNull(openApi);
        Long openLogId = null;
        String response = "";
        try {
            IOpenApp openApp = getOpenAppFromRequest(openRequest);
            openRequest.setOpenApp(openApp);
            openRequest.check();
            Object object = proceedingJoinPoint.proceed();
            openLogId = addOpenLog(
                    openRequest.getOpenApp(),
                    AirHelper.getRequest().getRequestURI(),
                    openRequest.decodeContent()
            );
            if (object instanceof Json json) {
                // 日志记录原始数据
                response = Json.toString(json);
                // 如果是Json 需要将 Json.data 对输出的数据进行加密
                json.setData(OpenResponse.encodeResponse(openRequest.getOpenApp(), json.getData()));
            }
            updateLogResponse(openLogId, response);
            return object;
        } catch (ServiceException serviceException) {
            response = Json.toString(Json.create()
                    .setCode(serviceException.getCode())
                    .setMessage(serviceException.getMessage())
            );
            updateLogResponse(openLogId, response);
            throw serviceException;
        } catch (Exception exception) {
            updateExceptionResponse(openLogId, exception);
            throw exception;
        }
    }

    /**
     * <h2>从请求对象中获取 {@code OpenApp}</h2>
     *
     * @param openRequest {@code OpenRequest}
     * @return {@code OpenApp}
     */
    private @NotNull IOpenApp getOpenAppFromRequest(@NotNull OpenRequest openRequest) {
        ServiceError.INVALID_APP_KEY.when(!StringUtils.hasText(openRequest.getAppKey()));
        ServiceError.SERVICE_ERROR.whenNull(openAppService, "注入OpenAppService失败");
        IOpenApp openApp = openAppService.getByAppKey(openRequest.getAppKey());
        ServiceError.INVALID_APP_KEY.whenNull(openApp);
        return openApp;
    }

    /**
     * <h2>添加日志</h2>
     *
     * @param openApp     {@code OpenApp}
     * @param url         请求 {@code URL}
     * @param requestBody 请求数据
     * @return 日志ID
     */
    private @Nullable Long addOpenLog(IOpenApp openApp, String url, String requestBody) {
        if (Objects.nonNull(openLogService)) {
            return openLogService.addRequest(openApp, url, requestBody);
        }
        return null;
    }

    /**
     * <h2>更新日志返回数据</h2>
     *
     * @param openLogId    日志 {@code ID}
     * @param responseBody 返回值
     */
    private void updateLogResponse(Long openLogId, String responseBody) {
        if (Objects.isNull(openLogId) || Objects.isNull(openLogService)) {
            return;
        }
        openLogService.updateResponse(openLogId, responseBody);
    }

    /**
     * <h2>更新日志异常</h2>
     *
     * @param openLogId 日志 {@code ID}
     * @param exception 异常
     */
    private void updateExceptionResponse(Long openLogId, Exception exception) {
        if (Objects.isNull(openLogId)) {
            return;
        }
        updateLogResponse(openLogId, Json.toString(Json.create().setMessage(exception.getMessage())));
    }
}

4.3 OpenApi 请求体

接下来对请求进行封装,限制一些必要的参数,以及加解密的方法:

java

@Slf4j
@Setter
public class OpenRequest {
    /**
     * <h2>防重放缓存前缀</h2>
     */
    private static final String NONCE_CACHE_PREFIX = "NONCE_";

    /**
     * <h2>防重放时长</h2>
     */
    private static final int NONCE_CACHE_SECOND = 300;

    /**
     * <h2>{@code AppKey}</h2>
     */
    @NotBlank(message = "AppKey不能为空")
    @Getter
    private String appKey;

    /**
     * <h2>版本号</h2>
     */
    @NotNull(message = "版本号不能为空")
    private int version;

    /**
     * <h2>请求毫秒时间戳</h2>
     */
    @NotNull(message = "请求毫秒时间戳不能为空")
    private long timestamp;

    /**
     * <h2>加密后的业务数据</h2>
     */
    @NotBlank(message = "业务数据包体不能为空")
    private String content;

    /**
     * <h2>签名字符串</h2>
     */
    @NotBlank(message = "签名字符串不能为空")
    private String signature;

    /**
     * <h2>请求随机串</h2>
     */
    @NotBlank(message = "请求随机串不能为空")
    private String nonce;

    /**
     * <h2>当前请求的应用</h2>
     */
    @Getter
    private IOpenApp openApp;

    /**
     * <h2>强转请求数据到指定的类对象</h2>
     *
     * @param clazz 业务数据对象类型
     */
    public final <T extends RootModel<T>> T parse(Class<T> clazz) {
        String json = decodeContent();
        try {
            return Json.parse(json, clazz);
        } catch (Exception e) {
            ServiceError.JSON_DECODE_FAIL.show();
            throw new ServiceException(e);
        }
    }

    /**
     * <h2>校验请求</h2>
     */
    final void check() {
        checkIpWhiteList();
        checkTimestamp();
        checkSignature();
        checkNonce();
    }

    /**
     * <h2>解密请求数据</h2>
     *
     * @return 请求数据
     */
    final String decodeContent() {
        String request = content;
        OpenArithmeticType appArithmeticType = DictionaryUtil.getDictionary(
                OpenArithmeticType.class, openApp.getArithmetic()
        );
        try {
            switch (appArithmeticType) {
                case AES -> request = AesUtil.create().setKey(openApp.getAppSecret())
                        .decrypt(request);
                case RSA -> request = RsaUtil.create().setPrivateKey(openApp.getPrivateKey())
                        .privateKeyDecrypt(request);
                case NO -> {
                }
                default -> throw new ServiceException("解密失败,不支持的加密算法类型");
            }
        } catch (ServiceException e) {
            throw e;
        } catch (Exception e) {
            ServiceError.DECRYPT_DATA_FAIL.show();
        }
        return request;
    }

    /**
     * <h2>时间戳检测</h2>
     */
    private void checkTimestamp() {
        long currentTimeMillis = System.currentTimeMillis();
        int nonceExpireMillisecond = NONCE_CACHE_SECOND * Constant.MILLISECONDS_PER_SECOND;
        ServiceError.TIMESTAMP_INVALID.when(
                timestamp > currentTimeMillis + nonceExpireMillisecond ||
                        timestamp < currentTimeMillis - nonceExpireMillisecond
        );
    }

    /**
     * <h2>验证IP白名单</h2>
     */
    private void checkIpWhiteList() {
        final String ipStr = openApp.getIpWhiteList();
        if (Objects.isNull(ipStr) || !StringUtils.hasText(ipStr)) {
            // 未配置IP白名单
            return;
        }
        String[] ipList = ipStr
                .replaceAll(Constant.SPACE, Constant.EMPTY_STRING)
                .split(Constant.LINE_BREAK);
        final String ip = RequestUtil.getIpAddress(AirHelper.getRequest());
        if (!StringUtils.hasText(ip)) {
            ServiceError.MISSING_REQUEST_ADDRESS.show();
        }
        if (Arrays.stream(ipList).toList().contains(ip)) {
            return;
        }
        ServiceError.INVALID_REQUEST_ADDRESS.show();
    }

    /**
     * <h2>签名验证结果</h2>
     */
    private void checkSignature() {
        ServiceError.SIGNATURE_INVALID.whenNotEquals(signature, sign());
    }

    /**
     * <h2>防重放检测</h2>
     */
    private void checkNonce() {
        RedisHelper redisHelper = AirHelper.getRedisHelper();
        Object savedNonce = redisHelper.get(NONCE_CACHE_PREFIX + nonce);
        ServiceError.REPEAT_REQUEST.whenNotNull(savedNonce);
        redisHelper.set(NONCE_CACHE_PREFIX + nonce, 1, NONCE_CACHE_SECOND);
    }

    /**
     * <h2>签名</h2>
     *
     * @return 签名后的字符串
     */
    private @org.jetbrains.annotations.NotNull String sign() {
        return DigestUtils.sha1Hex(openApp.getAppSecret() + appKey + version + timestamp + nonce + content);
    }
}

4.4 OpenApi 响应对象

同样的,我们也需要对响应对象进行封装,并提供一些加密的方法:

java

public class OpenResponse {
    /**
     * <h2>加密响应数据</h2>
     *
     * @param openApp 应用
     * @param data    数据
     * @return 加密后的数据
     */
    public static <A extends IOpenApp> @Nullable String encodeResponse(A openApp, Object data) {
        if (Objects.isNull(data)) {
            // 数据负载为空 直接返回
            return null;
        }
        String response = Json.toString(data);
        OpenArithmeticType appArithmeticType = DictionaryUtil.getDictionary(
                OpenArithmeticType.class, openApp.getArithmetic()
        );
        try {
            switch (appArithmeticType) {
                case AES -> response = AesUtil.create().setKey(openApp.getAppSecret())
                        .encrypt(response);
                case RSA -> response = RsaUtil.create().setPrivateKey(openApp.getPrivateKey())
                        .publicKeyEncrypt(response);
                case NO -> {
                }
                default -> throw new ServiceException(ServiceError.ENCRYPT_DATA_FAIL, "暂不支持的OpenApi加密算法");
            }
        } catch (Exception e) {
            ServiceError.ENCRYPT_DATA_FAIL.show();
        }
        return response;
    }
}

五、业务API接入

有了前面的步骤,我们可以进行业务API的开放了:

5.1 请求控制器

java

@ApiController("openApi/test")
@Description("测试API")
@Permission(login = false)
public class OpenTestController extends RootController {
    @Description("test")
    @PostMapping("test")
    @OpenApi
    public Json test(@RequestBody @Validated OpenRequest request) {
        return Json.data(request.parse(OpenTestModel.class));
    }

    @Description("test")
    @PostMapping("testArr")
    @OpenApi
    public Json testArr(@RequestBody @Validated OpenRequest request) {
        List<OpenTestModel> list = new ArrayList<>();
        list.add(new OpenTestModel().setName("test").setAge(1));
        list.add(new OpenTestModel().setName("test1").setAge(2));
        return Json.data(list);
    }
}

5.2 数据模型

java

@EqualsAndHashCode(callSuper = true)
@Data
@Accessors(chain = true)
public class OpenTestModel extends OpenBaseModel<OpenTestModel> {
    @NotBlank(message = "姓名不能为空")
    private String name;

    private Integer age;
}

到此,基本就已经完成了。

六、总结

我们基于这个设计,完成了标准应用的管理和标准OpenApi的接入开放,实现了系统的开放、安全、灵活等业务需求。

如果你对这篇文章实际的代码感兴趣,可以关注我们的 AirPowerJavaStarter 开源项目,里面包含了整个设计的源代码。

标签

发表评论