接口鉴权的代码实践
参考:HandlerMethodArgumentResolver用于统一获取当前登录用户
springboot自定义参数解析HandlerMethodArgumentResolver
WebMvcConfigurer.addArgumentResolvers自定义参数处理器不生效的原理与解决方案
百度api-鉴权认证机制
鉴权认证机制
字节数组与16进制字符串的相互转换
spring几种获取 HttpServletRequest 对象的方式
AES自动生成base64密钥加密解密
业务场景:模拟普通用户调用公共接口
业务细节:
此公共接口要求只允许普通用户调用10次。
此接口要求验证请求者的身份,保护传输中的数据,防止非法篡改,防止重放攻击。
实现思路:
用户需要携带认证token,认证字符串(签名),请求参数,请求公共接口。
token :用户通过登录,手机验证码等方法调用系统,颁发的认证标识。(验证请求者身份)
签名(sign):将请求参数和随机码(reqnum)和有效时间(timespace)拼接,根据密钥(signkey )加密得到。(保护传输中的数据,防止非法篡改,防止重复攻击)
鉴权服务检查token的合法性有效性,根据签名检查请求参数没有被非法篡改,根据签名中的有效时间来防止在有效时间外来重放请求。
简单流程图:
认证服务
最常见的就是使用用户名密码进行登录,在传统web项目中使用session来保证是同一会话,在微服务的架构中,常用redis来保存用户信息,来模拟session的作用。redis的单点登陆,实践参考:https://github.com/gengzi/GsjBlog 关注interceptor 和UserController 包下的代码。
鉴权服务
鉴权需要在执行真正controller接口方法之前执行,之前的web项目一般使用拦截器(Interceptor)或者过滤器(Filter)来实现请求的拦截,校验session是否存在,不存在即用户登陆失效,让用户重新登陆。基于springboot工程,可以使用注解(annotation),webmvcconfig的形式来针对请求拦截的处理,来实现鉴权服务。
项目源码地址:https://github.com/gengzi/codecopy
版本环境 :jdk1.8,Spring boot 2.2.7,mysql5.7,reids
上述已经说明了思路和一般常见的实现方式。
下面通过注解的方式,来实现。使得被注解修饰的controller接口,进行鉴权操作。
如果希望与所有的接口或者很多接口都执行鉴权操作,可以使用webmvcconfig配置对方法的处理。在参考的一些文章中,有具体说明。
fun.gengzi.codecopy.aop.BusinessAuthentication
/**
* 接口鉴权注解
* 用于标识那些controller 接口需要鉴权才能调用
*
* @author gengzi
* @date 2020年6月4日16:43:30
*
* 对于接口鉴权,也可以使用 拦截器,过滤器,指定那些路径进行拦截。
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface BusinessAuthentication {
/**
* 调用次数限定 默认 -1 不限制次数
* @return
*/
int callNumber() default -1;
// /**
// * ip 限定,限定指定范围的ip地址,访问该接口
// *
// * @return
// */
// String[] IPLimit() default {};
}
fun.gengzi.codecopy.aop.BusinessAuthenticationAspect
采用aop的思想,为添加了鉴权注解的方法,增强功能
/**
* 接口鉴权校验 aop
*/
@Aspect
@Configuration
public class BusinessAuthenticationAspect {
private Logger logger = LoggerFactory.getLogger(BusinessAuthenticationAspect.class);
// 鉴权服务接口url地址
@Value("${token.url.validToken}")
private String validToken;
// AES 密钥
@Value("${token.aeskey}")
private String aeskey;
// RSA 的密钥
@Value("${token.publickey}")
private String publickey;
//切入点
@Pointcut("@annotation(fun.gengzi.codecopy.aop.BusinessAuthentication)")
public void BusinessAuthenticationAspect() {
}
/**
* 环绕通知
* // 根据controller 配置的注解,执行该方法
* // 获取请求信息中的 token 校验,存在,执行 token 校验,不存在,阻断
* // 校验 token ,失败,阻断
* // 获取该注解配置的字段信息
* // 校验该用户是否还有调用次数 , 无 ,阻断
* // 校验该用户是否在允许的ip 范围, 无,阻断
* // 放行
*
* @param joinPoint
* @return
*/
@Around("BusinessAuthenticationAspect()")
public Object around(ProceedingJoinPoint joinPoint) {
logger.info("鉴权 - BusinessAuthenticationAspect start");
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
BusinessAuthentication businessAuthentication = method.getAnnotation(BusinessAuthentication.class);
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
// 校验token 是否有效
final String token = request.getHeader(HttpHeaders.AUTHORIZATION);
// 获取controller 方法名称
final String name = method.getName();
logger.info("被鉴权的方法 - BusinessAuthenticationAspect method name : {}", name);
// 如果没有 token,进行记录并抛出异常,响应前台
if (StringUtils.isBlank(token)) {
logger.info("无token结束 - BusinessAuthenticationAspect no token, end !");
throw new RrException("无权限", RspCodeEnum.NOTOKEN.getCode());
}
// 调用鉴权服务接口,鉴权
Boolean flag = reqValidToken(businessAuthentication, token);
Object obj;
try {
if (flag) {
// 鉴权成功,允许调用指定接口
logger.info("鉴权成功 - BusinessAuthenticationAspect success !");
obj = joinPoint.proceed();
} else {
logger.info("鉴权失败- BusinessAuthenticationAspect failure !");
throw new RrException("无权限");
}
} catch (Throwable e) {
logger.error("鉴权失败,出现异常- BusinessAuthenticationAspect failure ! , exception : {} ", e.getMessage());
throw new RrException("无权限");
}
return obj;
}
/**
* 调用接口鉴权服务
*
* // 调用鉴权服务,进行 token 校验,并返回该用户信息
* // 先定义 aes 秘钥,定义 rsa 的公钥和秘钥
* // 使用 aes 秘钥对 组装后的参数加密,再base64 转码 生成一个签名
* // 调用服务端, token 设置在请求头,请求体是 签名 , 其他固定的几个参数
*
* // 服务端校验 token, 解析请求体参数,将签名 使用 base64 解开,然后 aes 解密
* // 解密完成,比较 参数是否一致,一致说明,参数没有在传递时发生更改。
* // 服务端鉴权完毕,响应数据,将数据使用 rsa 私钥加密,使用base64 转码
*
* // 客户端获取响应数据,对应字段使用base64 转码,使用 rsa 公钥解密,解密完成,正式成功
*
* @param businessAuthentication 注解
* @param token 认证token
* @return 鉴权成功 true ,鉴权失败 false
*/
private Boolean reqValidToken(BusinessAuthentication businessAuthentication, String token) {
ConcurrentHashMap<String, String> concurrentHashMap = new ConcurrentHashMap<>();
StringBuilder signBuilder = new StringBuilder();
// 获取注解中的字段
final int callNumber = businessAuthentication.callNumber();
// 随机码
String reqNum = IdUtil.randomUUID();
// 组拼参数,例如: reqNum=uuid值&callNumber=33&
if (callNumber > 0) {
concurrentHashMap.put(AuthenticationConstans.CALLNUMBER, String.valueOf(callNumber));
}
concurrentHashMap.put(AuthenticationConstans.REQNUM, reqNum);
concurrentHashMap.forEach((key, value) -> signBuilder.append(key).append("=").append(value).append("&"));
// 将参数加密
String signStr = AESUtils.encrypt(signBuilder.toString(), aeskey)
.orElseThrow(() -> new RrException("error", RspCodeEnum.FAILURE.getCode()));
// 调用鉴权服务接口
logger.info("调用鉴权接口参数- BusinessAuthenticationAspect qryParams token :{},reqNum :{} ,signStr :{}", token, reqNum, signStr);
ReturnData returnData = getReturnData(token, reqNum, signStr);
logger.info("调用鉴权接口结果- BusinessAuthenticationAspect result", returnData.toString());
Boolean flag = false;
if (RspCodeEnum.SUCCESS.getCode() == returnData.getStatus()) {
Object info = returnData.getInfo();
if(info instanceof JSONObject){
TokenUserInfoResp.UserinfoData userinfoData = JSONUtil.toBean((JSONObject) info, TokenUserInfoResp.UserinfoData.class);
String certificateNo = userinfoData.getCertificateNo();
// RSA 解密
String certificateNoDecrpt = RSAUtils.decrypt(certificateNo, publickey).orElseThrow(() -> new RrException("error"));
if (callNumber == -1 || callNumber >= Integer.valueOf(certificateNoDecrpt) ){
flag = true;
}
}
}
return flag;
}
/**
* 调用鉴权服务接口
*
* @param token token
* @param reqNum 随机码
* @param signStr 签名
* @return {@link ReturnData}
*/
private ReturnData getReturnData(String token, String reqNum, String signStr) {
// 封装请求参数
RequestParamEntity requestParamEntity = new RequestParamEntity();
requestParamEntity.setReqNum(reqNum);
requestParamEntity.setSign(signStr);
String jsonBody = JSONUtil.parseObj(requestParamEntity, false).toStringPretty();
String body = HttpRequest.post(validToken)
.header(Header.AUTHORIZATION, token)
.body(jsonBody).execute().body();
return JSONUtil.toBean(body, ReturnData.class);
}
}
fun.gengzi.codecopy.business.authentication.controller.AuthenticationController
/**
* 接口鉴权controller
*
* @author gengzi
* @date 2020年6月5日10:42:08
*/
@Api(value = "接口鉴权", tags = {"接口鉴权"})
@Controller
@RequestMapping("/api/v1")
public class AuthenticationController {
private Logger logger = LoggerFactory.getLogger(AuthenticationController.class);
// RSA 的密钥
@Value("${token.secretkey}")
private String secretkey;
private final AuthenticationService authenticationService;
public AuthenticationController(AuthenticationService authenticationService) {
this.authenticationService = authenticationService;
}
@ApiOperation(value = "校验token", notes = "校验token")
@ApiImplicitParams({
@ApiImplicitParam(name = "RequestParamEntity", value = "请求参数实体", required = true)})
@ApiResponses({@ApiResponse(code = 200, message = "\t{\n" +
"\t \"status\": 200,\n" +
"\t \"info\": {\n" +
"\t }\n" +
"\t \"message\": \"success\",\n" +
"\t}\n")})
@PostMapping("/validToken")
@ResponseBody
public ReturnData validToken(@RequestBody RequestParamEntity requestParamEntity, HttpServletRequest request) {
ReturnData ret = ReturnData.newInstance();
final String token = request.getHeader(HttpHeaders.AUTHORIZATION);
// 校验token 是否有效
Boolean validToken = authenticationService.isValidToken(token);
if(validToken){
// 校验签名
Boolean validSign = authenticationService.isValidSign(requestParamEntity);
if(validSign){
// 根据token 获取用户信息,响应
// TODO 默认响应一个,该用户调用的次数,写死 10
TokenUserInfoResp.UserinfoData userinfoData = new TokenUserInfoResp.UserinfoData();
// 将返回字段都进行 rsa 加密
String numNo = RSAUtils.encrypt("1", secretkey).orElse("");
userinfoData.setCertificateNo(numNo);
ret.setSuccess();
ret.setInfo(userinfoData);
ret.setMessage("success");
return ret;
}
}
ret.setFailure("failure");
return ret;
}
}
fun.gengzi.codecopy.business.shorturl.controller.ShortUrlGeneratorController
当调用此接口时,先执行鉴权注解的方法,如果鉴权成功,再执行真正的目标接口。
@PostMapping("/getShortUrlByTest")
@ResponseBody
@BusinessAuthentication(callNumber = 10)
public ReturnData testgeneratorShortUrl(@RequestParam("longurl") String longurl) {
logger.info("getShortUrl start {} ", System.currentTimeMillis());
String shortUrl = shortUrlGeneratorService.generatorShortUrl(longurl);
ReturnData ret = ReturnData.newInstance();
ret.setSuccess();
ret.setMessage(shortUrl);
return ret;
}
上述在鉴权注解aop和鉴权服务中,分别使用两种加密方式,RSA 非对称加密,AES 对称加密。
对称加密
非对称加密
在响应给前端的一些数据中,也可以使用DSA加密后,响应,考虑脱敏后再响应。
其中在加密解密中,还使用base64 转码,为什么要使用base64 ,因为AES 和 RSA 加密后的数据都是字节数组,base64可以将字节数组转为字符串,方便于传输数据。
使用了 httool 工具类,参考github
fun.gengzi.codecopy.utils.AESUtils
fun.gengzi.codecopy.utils.RSAUtils
对于完善的接口设计,上述内容还是太薄弱。可以参考大厂官方api接口的设计,更加的复杂也会更加的安全,也可以作为设计的参考。
下面是百度api的一些设计,可以参考。
百度api-鉴权认证机制
这里关注一下请求头里面的 Authorization 认证字符串
Authorization 的生成策略: