[java]如何实现一个标准的开放应用服务架构
一、背景
基于 Java17
SpringBoot
JPA
我们已经完成了应用的各种业务服务的开发,突然有一天接到了新的需求:为了提高系统的开放、安全、合作等,我们的系统需要提供第三方系统的无缝对接的标准开放API。
于是我们着手设计了这套开放应用的服务标准架构。
二、需求分析
首先,我们需要分析需要支持的一些服务:
- 开放应用的管理
需要支持应用创建、删除、密钥的重置等
- 开放应用的安全
开放应用需要支持配置数据加密方式、签名、防重放、请求时间限制、IP白名单等安全机制
- 需要支持标准的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
开源项目,里面包含了整个设计的源代码。
发表评论