我们知道,做为一个web系统,少不了要调用别的系统的接口或者是提供接口供别的系统调用。从接口的使用范围也可以分为对内和对外两种,对内的接口主要限于一些我们内部系统的调用,多是通过内网进行调用,往往不用考虑太复杂的鉴权操作。但是,对于对外的接口,我们就不得不重视这个问题,外部接口没有做鉴权的操作就直接发布到互联网无疑是
而这不仅有暴露数据的风险,同时还有数据被篡改的风险,严重的甚至是影响到系统的正常运转!
接下来,我将结合实际代码,分享一套接口鉴权实践方法。
方案一 appId和secret
接口鉴权?那还不简单,给每个应用下发一个appId和secret,接口调用方每次携带appId和secret调用接口。但是这样真的安全吗?每次调用都要传输密码,很容易被截获。
方案二 appId和secret+token
调用方根据接口的URL和appId、secret组合在一起,然后加密生成一个token,服务端接收到对应请求之后按照同样的方法生成一个token,然后校验token的 正确性。但是这种方式每个url拼接上appId、secret生成的token是一样的,未授权系统截获后还是可以通过重放的方式,伪装成认证系统,调用这个接口。
方案三 appId和secret+token+时间戳
同方案二类似,token的生成过程中在加入时间戳,校验token正确性之前先校验时间戳是否在一定时间窗口内(比如说1分钟),如果超过一分钟,直接拒绝请求,通过后再校验token。
方案四 appId+token+时间戳
相对方案二,方案三的方法相对已经有很大提升了(同样参数不能无限制调用),但是仔细一想,还是有问题,攻击者截获请求以后,还是可以在一定时间窗口内通过重放攻击的方式发送请求。那么,有没有终极大招呢?
实际上,攻防之间没有绝对的安全,我们能做的是尽量提高攻击者的成本。这个方案虽然还有漏洞,但是实现起来简单,而且不会过度影响接口性能。权衡安全性、开发成本以及对系统性能的影响,这个方案算是比较合理的一个了。接下来,我将通过java代码一步一步实现这个鉴权功能。
首先,抽出一个AuthToken.java,定义了生成AuthToken以及校验token是否过期的方法
package com.info.examples.authentication;
import cn.hutool.core.map.MapUtil;
import cn.hutool.crypto.digest.DigestAlgorithm;
import cn.hutool.crypto.digest.Digester;
import lombok.Getter;
import lombok.ToString;
import java.nio.charset.StandardCharsets;
import java.util.Map;
@ToString
public class AuthToken {
// 默认超时时间 1 分钟
private static final long DEFAULT_EXPIRE_TIME_INTERVAL = 3 * 60 * 1000;
@Getter
private String token;
private long createTime;
@Getter
private long expiredTimeInterval = DEFAULT_EXPIRE_TIME_INTERVAL;
private static final Digester MD5 = new Digester(DigestAlgorithm.MD5);
public AuthToken(String token, long createTime) {
this.token = token;
this.createTime = createTime;
}
public AuthToken(String token, long createTime, long expiredTimeInterval) {
this.token = token;
this.createTime = createTime;
this.expiredTimeInterval = expiredTimeInterval;
}
public static AuthToken createToken(String appId, String secret, long createTime, String baseUrl, Map<String, String> params) {
String original = appId + secret + baseUrl + MapUtil.sortJoin(params, "&", "=", true) + createTime;
String token = MD5.digestHex(original, StandardCharsets.UTF_8);
return new AuthToken(token, createTime);
}
public boolean isExpired() {
return System.currentTimeMillis() < this.createTime + DEFAULT_EXPIRE_TIME_INTERVAL;
}
}
服务端需要存放已经下发给客户端的appId以及对应的secret,因为这个secret可以有多种存放方式,比如说内存、redis、zookeeper等多种方式,所以我们抽象出一个用于存放和获取secret的接口
package com.info.examples.authentication;
public interface CreditService {
/**
* 根据 appId 获取对应的 secret
*
* @param appId
* @return
*/
String getCreditByAppId(String appId);
/**
* 添加appId、secret
* @param appId
* @param secret
*/
void addSecret(String appId,String secret);
}
这里作为演示代码,实现一个基于内存存储的
package com.info.examples.authentication;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.Map;
@Service
public class InmemoryCreditServiceImpl implements CreditService {
private static final Map<String, String> CREDIT_MAP = new HashMap<>(1 << 2);
static {
// 这里做测试就直接添加一个appId
CREDIT_MAP.put("testAppId", "secretTest");
}
@Override
public String getCreditByAppId(String appId) {
return CREDIT_MAP.get(appId);
}
@Override
public void addSecret(String appId, String secret) {
if (!StringUtils.hasLength(appId) || !StringUtils.hasLength(secret)) {
return;
}
CREDIT_MAP.put(appId, secret);
}
}
接下来我们实现服务端再接收到请求以后做鉴权
package com.info.examples.authentication;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@RestController
public class AuthenticationController {
private final CreditService creditService;
public AuthenticationController(CreditService creditService) {
this.creditService = creditService;
}
@GetMapping("/auth")
public String auth(@RequestParam Map<String, String> params, HttpServletRequest request) {
final String appId = params.get("appId");
if (StringUtils.isEmpty(appId)) {
return "authentication failed";
}
final String secret = creditService.getCreditByAppId(appId);
final long createTime = Long.parseLong(params.get("createTime"));
final String baseurl = request.getRequestURI();
final String token = params.get("token");
params.remove("token");
params.put("secret", secret);
AuthToken authToken = AuthToken.createToken(appId, secret, createTime, baseurl, params);
log.info(authToken.toString());
if (!authToken.isExpired()) {
return "authentication failed";
}
if (!ObjectUtils.nullSafeEquals(token, authToken.getToken())) {
return "authentication failed";
}
// 执行具体业务逻辑
params.forEach((k, v) -> log.info("{} = {}", k, v));
return "success";
}
}
下面我们写一个接口来测试
package com.info.examples.authentication;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@RestController
public class TestAuthController {
@GetMapping("/testAuth")
public String testAuth() {
RestTemplate template = new RestTemplate();
Map<String, String> parameterMap = new HashMap<>(1 << 2);
String name = "zhangsan";
int age = 18;
String baseUrl = "/auth";
String appId = "testAppId";
long createTime = System.currentTimeMillis();
String secret = "secretTest";
parameterMap.put("name", name);
parameterMap.put("age", String.valueOf(age));
parameterMap.put("appId", appId);
parameterMap.put("createTime", String.valueOf(createTime));
parameterMap.put("secret", secret);
AuthToken authToken = AuthToken.createToken(appId, secret, createTime, baseUrl, parameterMap);
log.info(authToken.toString());
String requestUrl = "http://localhost:8080/auth?name=" + name + "&age=" + age +
"&appId=" + appId + "&createTime=" + createTime + "&token=" + authToken.getToken();
final String result = template.getForObject(requestUrl, String.class, parameterMap);
return result;
}
}
打开浏览器,访问http://localhost:8080/test,发现我们已经实现了鉴权的效果,但是每个接口前面都有一大堆鉴权的逻辑,这代码太那啥了
怎么处理呢?很简单,遇到这种情况我们可以使用注解+AOP的方式来优化代码,开始改造…
定义一个注解
package com.info.examples.authentication.annotation;
import java.lang.annotation.*;
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface Authentication {
}
通过切面实现验证token的功能
package com.info.examples.authentication.aop;
import com.info.examples.authentication.AuthToken;
import com.info.examples.authentication.CreditService;
import com.info.examples.authentication.annotation.Authentication;
import com.info.examples.authentication.exception.AuthenticationException;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.aop.aspectj.MethodInvocationProceedingJoinPoint;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
/**
* @desc 使用数字信封方式对输入字段解密,对输出数据加密
*/
@Slf4j
@Aspect
@Component
public class AuthenticationAop {
private final CreditService creditService;
public AuthenticationAop(CreditService creditService) {
this.creditService = creditService;
}
@Pointcut("@annotation(com.info.examples.authentication.annotation.Authentication)")
public void pointCutMethodBefore() {
}
@Before("pointCutMethodBefore()")
public void doBefore(JoinPoint point) {
MethodInvocationProceedingJoinPoint mjp = (MethodInvocationProceedingJoinPoint) point;
MethodSignature signature = (MethodSignature) mjp.getSignature();
Method method = signature.getMethod();
Authentication annotation = method.getAnnotation(Authentication.class);
if (annotation != null) {
HttpServletRequest request = getRequest(point.getArgs());
if (StringUtils.isEmpty(request)) {
throw new AuthenticationException("【鉴权失败】,获取HttpServletRequest");
}
String appId = request.getParameter("appId");
if (StringUtils.isEmpty(appId)) {
throw new AuthenticationException("【鉴权失败】,appId不存在");
}
final String secret = creditService.getCreditByAppId(appId);
final String token = request.getParameter("token");
final long createTime = Long.parseLong(request.getParameter("createTime"));
Map<String, String> params = new HashMap<>(1 << 2);
final String baseurl = request.getRequestURI();
request.getParameterMap().forEach((k, v) -> params.put(k, v[0]));
params.remove("token");
params.put("secret", secret);
AuthToken authToken = AuthToken.createToken(appId, secret, createTime, baseurl, params);
log.info(authToken.toString());
if (!authToken.isExpired()) {
throw new AuthenticationException("【鉴权失败】,token已过期");
}
if (!ObjectUtils.nullSafeEquals(token, authToken.getToken())) {
throw new AuthenticationException("【鉴权失败】,token错误");
}
}
}
private HttpServletRequest getRequest(Object[] args) {
for (Object o : args) {
if (o instanceof HttpServletRequest) {
return (HttpServletRequest) o;
}
}
return null;
}
}
这个时候,controller层就不用关注鉴权的逻辑了,只需添加一个@Authentication注解即可。
package com.info.examples.authentication;
import com.info.examples.authentication.annotation.Authentication;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.util.Map;
@Slf4j
@RestController
public class AuthenticationController {
@GetMapping("/auth")
@Authentication
public String auth(@RequestParam Map<String, String> params, HttpServletRequest request) {
// 执行具体业务逻辑
params.forEach((k, v) -> log.info("{} = {}", k, v));
return "success";
}
}
附上自定义异常代码
package com.info.examples.authentication.exception;
import lombok.*;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class AuthenticationException extends RuntimeException {
private String msg;
private String code;
public AuthenticationException(String msg) {
this.code = "5000";
this.msg = msg;
}
}
现在的代码是不是清爽多了
我们知道,做为一个web系统,少不了要调用别的系统的接口或者是提供接口供别的系统调用。从接口的使用范围也可以分为对内和对外两种,对内的接口主要限于一些我们内部系统的调用,多是通过内网进行调用,往往不用考虑太复杂的鉴权操作。但是,对于对外的接口,我们就不得不重视这个问题,外部接口没有做鉴权的操作就直接发布到互联网无疑是
而这不仅有暴露数据的风险,同时还有数据被篡改的风险,严重的甚至是影响到系统的正常运转!
接下来,我将结合实际代码,分享一套接口鉴权实践方法。
方案一 appId和secret
接口鉴权?那还不简单,给每个应用下发一个appId和secret,接口调用方每次携带appId和secret调用接口。但是这样真的安全吗?每次调用都要传输密码,很容易被截获。
方案二 appId和secret+token
调用方根据接口的URL和appId、secret组合在一起,然后加密生成一个token,服务端接收到对应请求之后按照同样的方法生成一个token,然后校验token的 正确性。但是这种方式每个url拼接上appId、secret生成的token是一样的,未授权系统截获后还是可以通过重放的方式,伪装成认证系统,调用这个接口。
方案三 appId和secret+token+时间戳
同方案二类似,token的生成过程中在加入时间戳,校验token正确性之前先校验时间戳是否在一定时间窗口内(比如说1分钟),如果超过一分钟,直接拒绝请求,通过后再校验token。
方案四 appId+token+时间戳
相对方案二,方案三的方法相对已经有很大提升了(同样参数不能无限制调用),但是仔细一想,还是有问题,攻击者截获请求以后,还是可以在一定时间窗口内通过重放攻击的方式发送请求。那么,有没有终极大招呢?
实际上,攻防之间没有绝对的安全,我们能做的是尽量提高攻击者的成本。这个方案虽然还有漏洞,但是实现起来简单,而且不会过度影响接口性能。权衡安全性、开发成本以及对系统性能的影响,这个方案算是比较合理的一个了。接下来,我将通过java代码一步一步实现这个鉴权功能。
首先,抽出一个AuthToken.java,定义了生成AuthToken以及校验token是否过期的方法