验签机制:
是什么(what)
验签机制是一种安全校验机制,通过代码层面进行修改,提高安全性。在请求中添加一些安全校验的参数进行校验,防止参数被篡改。同时可以引入时间戳,可以控制接口的时效性。自定义的验签规则以及验签的额外参数,也可以校验请求方是否合法。
在什么场景下用 (where)
当对接口有一定的安全性要求,且非公开接口一般都可以使用该方式。比如自己app的接口,网页的接口,服务间的接口等。
什么时候用 (when)
当发起请求时,进行验签计算,将验签结果和时间戳等参数加到请求中。服务端接收到请求时,可以用拦截器统一处理。对验签进行校验,然后再进行下一步操作。
怎么用 (how)
重头戏在这,该如何使用验签
验签的机制是,将参数进行某个规则的加密,这个规则只有你的服务端和客户端知道,参数中除了正常的请求参数外,多了一个验签参数 sign。后台拿到请求参数后,可以将参数用同样的方法加密,然后与sign比对,如果一致就认为没有被篡改。
请求参数和时间戳一起加密,因为时间戳是变化的,所以说加密出来的结果也是变化的。同时请求参数中带着时间戳参数,服务端可以根据时间戳来限制单次请求的有效时间,比如说单次请求1分钟内有效。那么之后的请求,就算原封不动的拿过来,因为时间戳的限制,不是当时的请求就是无效的,获取不到数据。然而要修改参数中的时间戳,又跟加密验签sign不一致,也得不到返回。
服务端个客户端都是我们自己的程序,或者说都是我们授权的。那么我就可以在服务端设置一个参数secret。然后我们把secret告诉给客户端。当然不是走网络 请求,而是通过线下的方式,比如说发邮件啊啥的。然后客户端会把secret存在自己本地,服务器也存一个secret。加密的时候,将secret拼接在要加密的字符串上,secret本身不进行网络传输。这样,在原文中就有一个不可见的字符,别人通过网络也拿不到真正的原文。这样破解起来就不容易了
我们还可以设置加密秘钥,服务端和客户端知道,也不参与传输。这样也更安全。而且加密不用选择对称加密,采用MD5这种非对称加密就可以,因为服务器不需要解密,服务器只用通过相同的加密规则得到一个相同的结果就可以了
然后还有数字签名,其实本质上是一样的,但数字签名更加规范一些。简单来说就是通过提供 可鉴别 的 数字信息 验证 自身身份 的一种方式。一套 数字签名 通常定义两种 互补 的运算,一个用于 签名,另一个用于 验证。分别由 发送者 持有能够 代表自己身份 的 私钥 (私钥不可泄露),由 接受者 持有与私钥对应的 公钥 ,能够在 接受 到来自发送者信息时用于 验证 其身份。
附代码:spring boot 拦截器实现
拦截器配置
@Configuration
public class ApiConfig implements WebMvcConfigurer {
@Autowired
private ApiInterceptor apiInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
//注册TestInterceptor拦截器
InterceptorRegistration registration = registry.addInterceptor(apiInterceptor);
registration.addPathPatterns("/api/**"); //所有路径都被拦截
}
}
拦截器实现
@Slf4j
@Component
public class ApiInterceptor implements HandlerInterceptor {
@Value("${api.secret}")
private String secret;
/**
* 在请求处理之前进行调用(Controller方法调用之前)
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
try {
log.info("请求参数:" + JSON.toJSONString(request.getParameterMap()));
// 校验请求时间,三分钟以内 获取当前时间戳
int nowTimestamp = (int) (System.currentTimeMillis() / 1000);
// 获取发起请求时的时间戳
int requestTimestamp = Integer.parseInt(request.getParameter("timestamp"));
// 如果请求时间超过3分钟则请求无效
if (Math.abs(nowTimestamp - requestTimestamp) > 3 * 60) {
AjaxResult ajaxResult = AjaxResult.error("请求时间无效");
ServletUtils.renderString(response, JSON.toJSONString(ajaxResult));
return false;
}
StringBuilder sb = new StringBuilder();
sb.append(secret);
// 将发送请求页面中form表单里所有具有name属性的表单对象获取(包括button).返回一个Enumeration类型的枚举
Enumeration<String> params = request.getParameterNames();
while (params.hasMoreElements()) {
String paramName = params.nextElement();
// 过滤掉时间戳和sign这两个属性
if (!paramName.equals("timestamp") && !paramName.equals("sign")) {
sb.append(paramName);
sb.append("=[");
sb.append(request.getParameter(paramName));
sb.append("]");
}
}
sb.append(requestTimestamp);
log.info("验签加密前:" + sb);
String resultString = MD5Util.md5(sb.toString());
log.info("验签加密后:" + resultString);
if (resultString.equals(request.getParameter("sign"))) {
return true;
}
} catch (Exception e) {
log.error(e.toString());
}
AjaxResult ajaxResult = AjaxResult.error("验签校验失败");
ServletUtils.renderString(response, JSON.toJSONString(ajaxResult));
return false;
}
}
token机制:
是什么(what)
token机制也是常用的一种接口授权认证的机制。token在计算机身份认证中是令牌(临时)的意思,在词法分析中是标记的意思。一般作为邀请、登录系统使用。
当用户登录后,产生一个token,之后所有用户请求,都需要携带token。当token失效请求失败
在什么场景下用 (where)
一般用在有登录场景的接口中,如app的用户登录后的一系列操作。这个也没有严格的限制,只要是涉及到用户认证的,都可以采用token机制。服务对服务间的接口,也有采用token机制的。
什么时候用 (when)
当用户登录时,传参用户名密码,有时为了防止暴力破解请求,会加入验证码,或者限制ip请求次数。当用户登录请求传到后台,后台接口验证登录用户名密码,返回一个生成的token。token具有时效性,和自动延时性。后续请求都需要携带token,token验证通过则访问成功。
登录请求不是仅仅为了验证密码返回用户信息的,而是登录要管理起来后面所有跟用户权限有关的接口,不登录是不能用的。
怎么用 (how)
我们登陆接口中可以加上一个token验证机制,就是说,如果登录成功,我们随机生成一串字符,把这个字符作为key,关联存储user对象。然后我们返回这个key。之后的所有请求都需要携带这个key,后面的请求过来后,先通过key查找缓存中是否有User对象。如果有,说明已经登录过了,就有权限访问,反之则没有权限访问。
同时,由于我们使用了key关联user对象,那么我们也不需要返回user对象。后面的请求也不需要携带user对象中的信息过来。因为我们缓存中已经存储了user对象,通过key查了出来,我们直接使用查出来的user对象作为参数就可以了。这样也避免了user对象的泄露。
key也可以添加有效时间的信息,比如说我们可以记录上一次请求key的时间,然后下一次请求就有了一个现在的时间。如果说登录操作有效时间是2小时,那么就可以拿现在的时间减去上一次请求的时间,得到一个差值,然后比较这个差值是否小于2小时,如果小于的话则是在有效时间内,允许请求。同时更新一下最后一次请求时间为当前时间,下一次请求继续判断。如果是超时了,就可以将缓存的user给删掉,直接返回错误,重新登录。用户退出也是一样的,删除掉缓存的user对象,那么以后的请求就找不到user对象,就不允许请求了
上代码:spring boot + spring security 实现,仅供参考。只展示关键代码,相关内容不再具体展示
登录控制器
/**
* 登录验证
*
* @author jdac
*/
@RestController
public class SysLoginController
{
@Autowired
private SysLoginService loginService;
@Autowired
private TokenService tokenService;
/**
* 登录方法
*
* @param loginBody 登录信息
* @return 结果
*/
@PostMapping("/login")
public AjaxResult login(@RequestBody LoginBody loginBody)
{
AjaxResult ajax = AjaxResult.success();
// 生成令牌
String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
loginBody.getUuid());
ajax.put(Constants.TOKEN, token);
return ajax;
}
/**
* 获取用户信息
*
* @return 用户信息
*/
@GetMapping("getInfo")
public AjaxResult getInfo()
{
LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
SysUser user = loginUser.getUser();
AjaxResult ajax = AjaxResult.success();
ajax.put("user", user);
return ajax;
}
}
登录service方法
/**
* 登录验证
*
* @param username 用户名
* @param password 密码
* @param code 验证码
* @param uuid 唯一标识
* @return 结果
*/
public String login(String username, String password, String code, String uuid)
{
boolean captchaOnOff = configService.selectCaptchaOnOff();
// 验证码开关 这里验证码根据自己需要实现,不再进行代码展示
if (captchaOnOff)
{
validateCaptcha(username, code, uuid);
}
// 用户验证
Authentication authentication = null;
try
{
// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
authentication = authenticationManager
.authenticate(new UsernamePasswordAuthenticationToken(username, password));
}
catch (Exception e)
{
if (e instanceof BadCredentialsException)
{
//记录登录信息日志用,根据自身需求去实现,不再展开描述
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
throw new UserPasswordNotMatchException();
}
else
{
//记录登录信息日志用,根据自身需求去实现,不再展开描述
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
throw new CustomException(e.getMessage());
}
}
//记录登录信息日志用,根据自身需求去实现,不再展开描述
AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
//记录登录信息
recordLoginInfo(loginUser.getUser());
// 生成token
return tokenService.createToken(loginUser);
}
/**
* 记录登录信息
*/
public void recordLoginInfo(SysUser user)
{
user.setLoginIp(IpUtils.getIpAddr(ServletUtils.getRequest()));
user.setLoginDate(DateUtils.getNowDate());
userService.updateUserProfile(user);
}
token service 相关代码
/**
* token验证处理
*
* @author jdac
*/
@Component
public class TokenService
{
// 令牌自定义标识
@Value("${token.header}")
private String header;
// 令牌秘钥
@Value("${token.secret}")
private String secret;
// 令牌有效期(默认30分钟)
@Value("${token.expireTime}")
private int expireTime;
protected static final long MILLIS_SECOND = 1000;
protected static final long MILLIS_MINUTE = 60 * MILLIS_SECOND;
private static final Long MILLIS_MINUTE_TEN = 20 * 60 * 1000L;
@Autowired
private RedisCache redisCache;
/**
* 获取用户身份信息
*
* @return 用户信息
*/
public LoginUser getLoginUser(HttpServletRequest request)
{
// 获取请求携带的令牌
String token = getToken(request);
if (StringUtils.isNotEmpty(token))
{
try
{
Claims claims = parseToken(token);
// 解析对应的权限以及用户信息
String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
String userKey = getTokenKey(uuid);
LoginUser user = redisCache.getCacheObject(userKey);
return user;
}
catch (Exception e)
{
}
}
return null;
}
/**
* 设置用户身份信息
*/
public void setLoginUser(LoginUser loginUser)
{
if (StringUtils.isNotNull(loginUser) && StringUtils.isNotEmpty(loginUser.getToken()))
{
refreshToken(loginUser);
}
}
/**
* 删除用户身份信息
*/
public void delLoginUser(String token)
{
if (StringUtils.isNotEmpty(token))
{
String userKey = getTokenKey(token);
redisCache.deleteObject(userKey);
}
}
/**
* 创建令牌
*
* @param loginUser 用户信息
* @return 令牌
*/
public String createToken(LoginUser loginUser)
{
String token = IdUtils.fastUUID();
loginUser.setToken(token);
setUserAgent(loginUser);
refreshToken(loginUser);
Map<String, Object> claims = new HashMap<>();
claims.put(Constants.LOGIN_USER_KEY, token);
return createToken(claims);
}
/**
* 验证令牌有效期,相差不足20分钟,自动刷新缓存
*
* @param loginUser
* @return 令牌
*/
public void verifyToken(LoginUser loginUser)
{
long expireTime = loginUser.getExpireTime();
long currentTime = System.currentTimeMillis();
if (expireTime - currentTime <= MILLIS_MINUTE_TEN)
{
refreshToken(loginUser);
}
}
/**
* 刷新令牌有效期
*
* @param loginUser 登录信息
*/
public void refreshToken(LoginUser loginUser)
{
loginUser.setLoginTime(System.currentTimeMillis());
loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
// 根据uuid将loginUser缓存
String userKey = getTokenKey(loginUser.getToken());
redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
}
/**
* 设置用户代理信息
*
* @param loginUser 登录信息
*/
public void setUserAgent(LoginUser loginUser)
{
UserAgent userAgent = UserAgent.parseUserAgentString(ServletUtils.getRequest().getHeader("User-Agent"));
String ip = IpUtils.getIpAddr(ServletUtils.getRequest());
loginUser.setIpaddr(ip);
loginUser.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
loginUser.setBrowser(userAgent.getBrowser().getName());
loginUser.setOs(userAgent.getOperatingSystem().getName());
}
/**
* 从数据声明生成令牌
*
* @param claims 数据声明
* @return 令牌
*/
private String createToken(Map<String, Object> claims)
{
String token = Jwts.builder()
.setClaims(claims)
.signWith(SignatureAlgorithm.HS512, secret).compact();
return token;
}
/**
* 从令牌中获取数据声明
*
* @param token 令牌
* @return 数据声明
*/
private Claims parseToken(String token)
{
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
}
/**
* 从令牌中获取用户名
*
* @param token 令牌
* @return 用户名
*/
public String getUsernameFromToken(String token)
{
Claims claims = parseToken(token);
return claims.getSubject();
}
/**
* 获取请求token
*
* @param request
* @return token
*/
private String getToken(HttpServletRequest request)
{
String token = request.getHeader(header);
if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.TOKEN_PREFIX))
{
token = token.replace(Constants.TOKEN_PREFIX, "");
}
return token;
}
private String getTokenKey(String uuid)
{
return Constants.LOGIN_TOKEN_KEY + uuid;
}
}
token过滤器代码
/**
* token过滤器 验证token有效性
*
* @author jdac
*/
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
{
@Autowired
private TokenService tokenService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException
{
LoginUser loginUser = tokenService.getLoginUser(request);
if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication()))
{
tokenService.verifyToken(loginUser);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
chain.doFilter(request, response);
}
}
请求加密:
是什么(what)
这个很好理解,就是对请求参数以及返回结果的加密处理。防止请求参数或者返回结果的泄露。
在什么场景下用 (where)
对于一些敏感信息,如用户注册的手机号,身份证号。返回的用户信息,一些其他的用户敏感数据等。若被拦截泄露,会造成很严重的后果。我们对于这些数据进行加密,即使拦截到请求,也解析不了。
什么时候用 (when)
当发起请求之前,先对参数进行一下加密。服务器返回结果之前,也对返回结果进行一下加密。
怎么用 (how)
这里加密方式有很多种,但一般都是采用对称可解密的方式,否则不能解密的数据无意义。常见的有SHA、AES/DES、Base64等。
其实还有一个,平时不怎么注意。那就是HTTPS。在HTTP的基础上通过传输加密和身份认证保证了传输过程的安全性 。 HTTPS 就是在HTTP的基础上加了SSL。
这个是非代码层面,但HTTPS也不是绝对安全。如果安全等级要求低,HTTPS也够用了,不需要代码上增加工作量。如果安全等级要求比较高,那么还是需要对自己的请求进行加密的。
加解密还有一个好处,那就是能在一定程度上防止SQL注入。当然,在数据处理层还是要进行处理的,接口这里只能是一种预防拦截处理。因为SQL注入最终是落到数据层面的。
这里就不再进行代码展示了
白名单:
是什么(what)
白名单,就是一种访问限制。当请求方地址在白名单中,则允许访问,否则拒绝访问。这个比较好理解
在什么场景下用 (where)
白名单这种形式,一般是用在服务对服务间的接口时进行使用的,或者是内部服务,分配使用者ip地址。只有请求方的地址是不变的,才可以添加到白名单内。
如果是面向互联网用户的,用户换个网络都会产生不同ip,而且普通用户也不会使用顶级ip,一般都是动态代理ip。这种的无法记录请求方地址。
但是还有另一种形式,那就是添加Mac白名单。这种一般也是针对内部应用,分配用户记录Mac或者唯一序列号UUID。但这种一般仅限于电脑程序和手机app中。能够有较大的权限以及封闭性,请求不易被篡改。
获取唯一UUID其实还有个场景被忽略了,那就是用户免登录。当用户首次登录后获取手机唯一序列号带到服务器中,服务器记录用户唯一序列号。当下次再打开,只要校验唯一序列号是否存在,以及上一次登录时什么时间就可以实现免登录了。当然,免登录有很多种形式,这也只是其中之一罢了。
另外要注意一点,IP地址也可以被伪造,如果是伪造的IP,那么白名单依然是有风险存在的。
什么时候用 (when)
白名单的使用场景见上
怎么用 (how)
一般,ip白名单可以在网络层进行限制,也可以在服务代码层进行限制。这都可以。但mac或者UUID一般是只能在服务代码层进行限制。
在数据库内或者在持久化文件中,记录可以访问的ip地址。当请求过来后,获取请求头中的ip,然后判断是否在自己记录的ip地址列表中,如果在则允许访问,不在则拒绝访问。
其他:
以上的方式并不一定只能选择其一使用,而是可以进行混合使用。可以同时使用多重方式,增加系统的安全性。
我们在平时使用一些第三方接口的时候,都是一个非常好的学习机会,去看别人对于接口安全是如何处理的。
一些公开的,如聚合数据接口,只加了一个key做验证。这个key是需要登录后进行申请的。而且对于key也没有其他过多的校验。好处就是请求简单方便,坏处就是通过拦截很容易能获取到别人的key,然后就可以随意使用别人的key了。
还有如腾讯,比如小程序或者公众号的接口对接,这些就是相当麻烦。因为也是要求服务对服务的,所以也有白名单的设置,也有域名的设置。另外请求上也有一系列的token secret等。这样的请求就比较麻烦,而好处就是安全。
总结
不难发现一个问题,想要更安全,就免不了麻烦。想要简单,那么在安全性上就会有一些损失。这就需要开发者进行权衡。
另外,安全也不仅仅只有上面我说的一些。信息安全其核心包括保密性、完整性、可用性、可控性和不可否认性五个安全目标。
除了考虑接口数据的安全外,还需要考虑稳定性、可靠性。这也是安全要考虑的事情。因为不是所有的黑客攻击都是冲着你的数据来的,也有就是冲着你破坏你来的。让你的服务失效,不能用。比如DDoS攻击,就是 通过大规模的访问,让你的服务拥挤不堪,最后崩溃。这种情况下就该考虑限制请求和提高服务器并发量等去解决了。
安全方面,还有很多事情呢,慢慢学吧