传送门
数据安全系列1:开篇
数据安全系列2:单向散列函数概念
数据安全系列3:密码技术概述
什么是认证?
一谈到认证,多数人的反应可能就是"用户认证" 。就是应用系统如何识别用户的身份,直接一点就是常说的"登录"功能,这可以说是一个系统中最基本的功能了:
认证(Authentication)、授权(Authorization)和凭证(Credentials)这三项可以说是一个系统中最基础的安全设计了,哪怕是再简陋的信息系统,大概也不可能忽略掉“用户登录”这个功能。
--------------------引自系统如何正确分辨操作用户的真实身份
而"登录"又是所有安全功能中的重中之重:没有经过用户认证的过程,所有的安全设计都这空中楼阁,这就意义着登录其实不是一件简单的事情:不仅仅是校验一下用户名、密码是否正确这么简单,而是一系列围绕认证展开的复杂问题:
- 账户和权限信息作为一种必须最大限度保障安全和隐私
- 同时又要兼顾各个系统模块、甚至是系统间共享访问的基础主数据
所以登录场景下的用户,除了一般意义上的真实的人,也可能不是一个真正的人:只要拥有用户名、密码并经过了系统的安全认证,就可以被系统所接受了。比如有些黑客程序,或者所谓的"攻击机器人",其实并不是真正的用户在操作。但是这里讨论的场景中,用户指的一般开发口中的各种应用系统,以及为了安全性而设计的应用身份识别!
应用身份认证
应用身份认证的场景,其实在开发中还是很常见的(可能对于非开发人员来说,倒不常见,因为一般用户操作的是时候以自己为主体的,所以不存在什么应用身份认证)。
API接口对于程序员来说(尤其是后端开发)几乎是每个人都接触过的,不论是开发API接口还是调用API接口都并不陌生。API接口一般是由应用系统开发出来供别的系统来调用,只要符合接口的规范或约定,一般都能调用成功。这里成功要说明一下:
- 不考虑网络环境,默认是通的
- 也不保证业务执行成功与否,只考虑是否满足参数、URL、请求方式等
调用API接口如果只满足基本要求就能调用,在安全性上其实是不够的。就好比一个系统如果没有"登录"这种基本的认证,任何人都能访问那不是一个道理吗?
在一般的内网环境里面,因为有防火墙的存在,其实对于应用之间的API接口调用的认证要求,倒并不是很严格。但是以下的一些情况却是不能忽视:
- 涉及外网业务,提供了对外的API接口调用
- 涉及敏感操作,比如转账汇款、删除资源的高危操作
- 涉及集中管理,比如一些开放网关、公共应用平台系统
- 其它一些暂时没有想到的......
有上面这些场景,系统就不能再"裸奔"了!对于具体怎么设计应用身份认证并没有统一的标准和既定的规范,放之四海皆准。不过还是有一些借鉴模式:
- 使用Oauth2协议的密码模式
- 使用消息认证码模式
具体使用Oauth2的密码模式还是消息论证码模式并没有明确的规定,主要看应用场景。如果是上面提到的开放网关、平台类系统,出于安全性及管理的需要,使用Oauht2的密码模式比较合适。如果是开发小型系统,也不用对接什么平台类的系统,要自主开发一套应用身份认证功能,可以采用消认证码模式,接下来可以具体讨论一下如何实现及对比之间的差异!
Oauth2密码模式
对于Oauth2协议前面讨论的足够多了,其中又专门介绍了Oauth2系列4:密码模式,所以不再赘述。
这里再简单画一个示意图来说明应用场景:
- A系统开发API接口,并到平台系统注册
- B系统调用API接口 ,也到平台系统注册
- 平台系统负责管理注册的应用(包括对应的接口等资源),并负责在系统间接口调用时进行身份论证
那应用身份认证这个场景跟密码模式具体有什么关系呢,或者说为什么可以采用密码模式来做API接口调用的控制?这里觉得有必要做一个探讨与解释。我们知道Oauth协议其实是一个授权协议(可参考Oauth2系列1:初识Oauth2):
看一下网站应用微信登录开发指南
从上面的时序图可以看出标准场景Oauth2的流程有真实用户参与,所以为了应对没有没有真实用户参与的情况,比如应用身份认证(一般都是应用间接口调用,比如服务间通过HTTP接口调用),Oauth2制定了密码模式来应对:将应用模拟为"用户",并也向应用颁发"账号-clientID"、"密码-clientSecret",应用通过账号、密码直接获取token来完成身份认证!上面流程就变成了下面这样:
消息认证码
如果说Oauth2的密码模式适用于平台类系统,提供了一种通用、与业务无关的身份认证方式,那么消息认证码就是另外一种相对更底层与业务参数有关的认证方式。关于消息认证码的概念,可以参考数据安全系列3:密码技术概述,那么为什么消息论证码可以达到身份论证的目的呢?再回顾一下消息论证码的过程:
- 在这样的交互过程中,交互的双方需要共享密钥,也即是前面的对称密钥
- 要计算MAC值,必须持有共享密钥,没有就无法计算MAC值,消息认证码正是利用此特性来完成所谓的认证的。
除此以外,还需要说明的是这个过程里面还依赖于单向散列函数的不可逆性!
密钥管理
从Oauh2协议可以看出,可以单独做一个注册服务,负责client_id、client_secret的管理,对网关这种这种平台系统是必要的。如果是对接系统很少甚至就一个,只要双方约定好"密钥"就行:比如服务提供方生成一个16位"随机数",并颁发给调用方作为"密钥",这样会更简单:
UUID.randomUUID().toString()
至于密钥的具体生成、传输、存储、管理也是一个很大话题,一般可能会涉及到KMS之类系统,这里就不展开了。
接下来模拟一个接口,看下通过消息认证码如何实现身份认证!假设有一个用户注册接口:
@PostMapping("register")public void register(@RequestParam("userName") String userName, @RequestParam("email") String email) {}
接受2个参数userName、email:规定只能拥有"密钥"的系统才能调用。
实现-版本1-基本功能
能最直接想到的办法是,检验参数内容是否符合要求:
- 调用方:将userName、email拼接起来生成消息认证码,并传递给服务方
- 服务方:接收userName、email,拼接起来生成消息认证码,并与调用方传递的认证码比较
- 如果一致,表示认证成功,不一致则不允许调用
通过这个分析,接口就要多加一个参数接收消息认证码,比如叫signature或digest:
@PostMapping("register")public void register(@RequestParam String userName, @RequestParam String email, @RequestParam String signature) {System.out.printf("userName:" + userName + ",email:" + email + ",signature:" + signature);}
这里还有一个问题就是如何生成消息认证码,这里提供一个Hmacsha256方法(可自行选择算法):
public static String genHmacSha256Sign(String message, String secret) {// 初始化密钥,这里使用一个示例密钥(在实际应用中,密钥应该保密)byte[] secretKeyBytes = secret.getBytes(StandardCharsets.UTF_8);SecretKeySpec secretKey = new SecretKeySpec(secretKeyBytes, "HmacSHA256");try {// 获取HMAC-SHA256的Mac实例Mac mac = Mac.getInstance("HmacSHA256");mac.init(secretKey);// 要签名的数据byte[] dataBytes = message.getBytes(StandardCharsets.UTF_8);mac.update(dataBytes);// 执行MAC计算byte[] resultBytes = mac.doFinal();// 编码为Base64字符串return Base64.getEncoder().encodeToString(resultBytes);} catch (Exception e) {throw new RuntimeException(e);}}
好,现在假定约定的密钥是:826270b4-542b-4e48-b48c-856bea6453db
注册的用户名、email分别是:张三、zhangsan@qq.com,客户端计算出来signature:
public static void main(String[] args) {String secret = "826270b4-542b-4e48-b48c-856bea6453db";String userName = "张三", email = "zhangsan@qq.com";String message = userName + email;System.out.println(genHmacSha256Sign(message, secret));}
输出摘要为:uTo95CYO1AchnvRK9uAJ1W+nc2bJo2p1IsOtLOdWpsk=
服务端的验证逻辑调成为:
@PostMapping("register")public String register(@RequestParam String userName, @RequestParam String email, @RequestParam String signature) throws UnsupportedEncodingException {System.out.printf("userName:" + userName + ",email:" + email + ",signature:" + signature);String message = userName + email;String sha256Sign = URLDecoder.decode(SignUtil.genHmacSha256Sign(message, "826270b4-542b-4e48-b48c-856bea6453db"), StandardCharsets.UTF_8.name());if (signature.equals(sha256Sign)) {return "success";}return "error";// 省略注册业务逻辑}
现在启动一下服务端,通过postman来调用一下:
调用成功,一个最基本的认证功能实现完成了!
实现-版本2-与业务解耦
上面的方式虽然实现了功能,不过还是会发现还是有一些问题:
- signature放在业务接口里面
- 要针对每个接口的参数单独约定好message的拼接规则(比如哪些参数参与认证、拼接顺序)
总之一句话,身份认证与业务接口没有强绑定了,所以最好把身份认证设计成一个通用的功能:
- 提供一个过滤器,在里面进行身份认证的检验,并且指定拦截的URL
- 为了统一message的拼接规则,统一规则接口的所有参数都参与拼接
所以约定:
- 将signature从业务接口里面提出来,入到header中传递
- 接口的入参统一用RequestBody的json形式接收,不再定义成RequestParam
改定代码,服务接口:
import com.tw.tsm.auth.dto.RegisterDtoReq;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;import java.io.UnsupportedEncodingException;@RestController
public class RegisterController {@PostMapping("register")public String register(@RequestBody RegisterDtoReq register) throws UnsupportedEncodingException {System.out.printf("userName:" + register.getUserName() + ",email:" + register.getEmail() + ",signature:" + signature);// 不再业务代码里面进行身份认证了// String message = userName + email;
// String sha256Sign = URLDecoder.decode(SignUtil.genHmacSha256Sign(message, "826270b4-542b-4e48-b48c-856bea6453db"), StandardCharsets.UTF_8.name());
// if (signature.equals(sha256Sign)) {
// return "success";
// }
// return "error";// 省略注册业务逻辑return null;}}@Data
@NoArgsConstructor
@AllArgsConstructor
public class RegisterDtoReq {private String userName;private String email;
}
过滤器:
import com.tw.tsm.base.util.RequestWrapper;
import com.tw.tsm.base.util.SignUtil;
import org.apache.commons.io.IOUtils;import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.nio.charset.StandardCharsets;public class VerityFilter implements Filter {@Overridepublic void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {ServletRequest requestWrapper = null;if (request instanceof HttpServletRequest) {requestWrapper = new RequestWrapper((HttpServletRequest) request);}// 在chain.doFiler方法中传递新的request对象if (requestWrapper == null) {chain.doFilter(request, response);} else {verity((HttpServletRequest) requestWrapper);chain.doFilter(requestWrapper, response);}}private void verity(HttpServletRequest requestWrapper) throws IOException {//获取请求中的流如何,将取出来的字符串,再次转换成流,然后把它放入到新request对象中。String requestBody = IOUtils.toString(requestWrapper.getInputStream(), StandardCharsets.UTF_8.name()).replaceAll("\r\n", "");System.out.printf(requestBody);String sha256Sign = SignUtil.genHmacSha256Sign(requestBody, "826270b4-542b-4e48-b48c-856bea6453db");String signature = requestWrapper.getHeader("signature");if (signature.equals(sha256Sign)) {return;}throw new IllegalArgumentException("参数异常!");}
}
包装的HttpServletRequest,用于读取Body:
import org.apache.commons.io.IOUtils;import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.*;public class RequestWrapper extends HttpServletRequestWrapper {private byte[] requestBody;private HttpServletRequest request;public RequestWrapper(HttpServletRequest request) throws IOException {super(request);this.request = request;}@Overridepublic BufferedReader getReader() throws IOException {return new BufferedReader(new InputStreamReader(getInputStream()));}@Overridepublic ServletInputStream getInputStream() throws IOException {if (requestBody == null) {ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();IOUtils.copy(request.getInputStream(), byteArrayOutputStream);this.requestBody = byteArrayOutputStream.toByteArray();}final ByteArrayInputStream bais = new ByteArrayInputStream(requestBody);return new ServletInputStream() {@Overridepublic int read() throws IOException {return bais.read();}@Overridepublic boolean isFinished() {return false;}@Overridepublic boolean isReady() {return false;}@Overridepublic void setReadListener(ReadListener readListener) {}};}}
注册Filter:
@Beanpublic FilterRegistrationBean httpServletRequestReplacedRegistration() {FilterRegistrationBean registration = new FilterRegistrationBean();registration.setFilter(new VerityFilter());registration.addUrlPatterns("/register");registration.addInitParameter("paramName", "paramValue");registration.setName("VerityFilter");registration.setOrder(1);return registration;}
客户端生成signature:
public static void main(String[] args) {String secret = "826270b4-542b-4e48-b48c-856bea6453db";String userName = "张三", email = "zhangsan@qq.com";
// String message = userName + email;JSONObject jsonObject = new JSONObject();jsonObject.put("userName", userName);jsonObject.put("email", email);String message = jsonObject.toJSONString();System.out.println(genHmacSha256Sign(message, secret));// System.out.println(genHmacSha256Sign(jsonObject.toString(), secret));}
现在启动一下服务端,通过postman来调用一下:
header里面也要传参数:
实现-版本3-防重放
经过迭代过的版本,已经将身份认证与业务接口解耦开了,不过这里还有一个安全问题,就是防重放攻击,具体的应对方案也比较成熟:
- 加时间戳-timestamp。该方法优点是不用额外保存其他信息。缺点是认证双方需要准确的时间同步,同步越好,受攻击的可能性就越小。但当系统很庞大,跨越的区域较广时,要做到精确的时间同步并不是很容易。所以一般会采用在指定时间范围,比如一分钟以内的请求才接受。并且单独使用时间戳,很难完全杜绝重放攻击
- 加随机数-nonce。该方法优点是认证双方不需要时间同步,双方记住(客户端生成、传递给服务端)使用过的随机数,如发现报文中有以前使用过的随机数,就认为是重放攻击。缺点是需要额外保存使用过的随机数,若记录的时间段较长,则保存和查询的开销较大。所以一般会采用时间戳+随机数方式的:一分钟以内的+此时间段内不重复的随机数请求才接受(存储采用redis,利用reids的TTL机制自动清理数据)
在实际中,常将方法(1)和方法(2)组合使用,这样就只需保存某个很短时间段内的所有随机数,而且时间戳的同步也不需要太精确。时间戳一般都是客户端生成,而nonce可以由客户端生成、也可以由服务端生成:
- 服务端生成的话,要额外增加一个接口级客户端单独获取nonce
- 客户端生成则不需要,可以简化调用逻辑
生成timestamp、nonce,也放到header中做为公共参数,并参与message的拼接:message = 摘要算法(业务参数的json字符串+timestamp+nonce)。这里就不再实现了,代码也不难