谈谈移动应用的安全性实践

作为一家大数据公司,Glow不仅重视用户的数据,更加注重数据的安全性。

本文将从用户注册流程出发,逐步介绍我们在提高数据安全性方面采用的一些策略方法,供读者参考。下面将从 Android服务端 两部分来进行讲解。

从注册说起

用户第一次打开app时便会进入注册页面,然后客户端会要求用户输入用户名、密码并传递给服务端去创建一个新的user。此时若通过明文传递用户名密码便是一个安全性隐患。或者说,如果有人监听注册API,那么很快就可以窃取到很多用户的账户数据,而且可以偷偷利用这些账户信息随时获取甚至更改用户数据。

这对于任何一家企业而言都是非常可怕的。

全站Https

因此,为了应对数据明文传输隐患这个问题,我们在自家所有app中都采用了Https方式通信。在Android端我们采用了Square家的OkHttp3作为网络层,为应用层提供Https服务。

下面先对Https的基本工作原理进行下介绍,以便后文的讲解。

  1. 首先,客户端去请求服务端的数字证书,这个证书包含了一个公钥。该证书购买后存储于我们自己服务器上。
  2. 当服务端收到客户端请求后,会把这个数字证书回传给客户端,由于是公钥,所以不害怕被窃取。
  3. 客户端收到数字证书后,先去验证证书的真实性。如果验证通过,就会从里面取出一个公钥
  4. 客户端本地生成一个随机数,作为未来的会话私钥,利用前面的公钥进行加密
  5. 客户端把加密后会话私钥回传给服务端,在这个过程中,即使加密后的会话私钥被窃取也不用担心,因为中间人并没有解密私钥,所以读不出里面的会话私钥
  6. 服务端接收到加密会话私钥后,利用从CA购买证书时获得的解密私钥进行解密读出真实会话私钥。至此,客户端与服务端同时拥有了一个只有它们二者知道的会话私钥,非对称加密连接建立完成。
  7. 一旦客户端和服务端连接建立起来后,未来的数据通信都利用这个会话私钥进行对称加密传输数据。

采用了https后,我们所有网络传输的数据都由明文变成了密文,即使中间有人能够监听到数据包,也不能轻易获取user的帐户密码信息。

听起来,安全性问题基本解决了。

然而实际上,在步骤3用户需要去验证数字证书时,如果这个验证过程被欺骗了呢

试想这样一种场景,如果在最开始,攻击者就拦截掉客户端与服务端的通信。当客户端在请求证书时,攻击者回传一个他自己的假证书,而且攻击者已经通过其他手段欺骗用户在手机上信任了这个假证书,那么当客户端接收到证书并去验证时,是**可以通过的**。

这也就意味着,一旦客户端遭受这样的攻击,未来客户端都会与一个虚假的中间人通信,而且中间人也可以拿着客户端传来的信息去与我们的服务端通信,而这个过程客户端和我们服务端完全不知道中间人的存在,这是很大的安全隐患。

公钥绑定(SSL Pinning)

为了防止客户端被虚假证书欺骗,可以采取的方式是把我们自己的公钥直接绑定给客户端,当客户端收到证书后,与提前绑定好的公钥进行验证,从而防止虚假证书的入侵。

在Android端,我们利用OkHttp3提供的CertificatePinner实现公钥绑定

OkHttpClient client = new OkHttpClient.Builder()
  .certificatePinner(
        new CertificatePinner.Builder().add("your_host", "your_public_key").build())
        .build();

至此,我们可以利用更为安全的https协议来传输用户名和密码,我们继续来看上面的注册流程。

Token机制

回到注册流程。当服务端拿到用户名密码后,会去创建一个新的user,同时我们会基于用户相关信息生成一个Token并回传给客户端。客户端在接收到Token后需要在本地进行存储。另外,由于每个http请求都是无状态的,因此未来客户端如果想把user信息传递给服务端时,就必须通过Token来传递,才能识别出某个请求的来源。

那么,我们应该如何在Android和服务端的代码里具体实现Token的传递解析有效性验证机制呢?

1. 首先在Android端,为了把Token信息存入到所有请求的http header里,我们采用了okhttp3提供的interceptor接口来。

OkHttpClient client = new OkHttpClient.Builder()
        .addInterceptor(new Interceptor() {
          @Override
          public Response intercept(Chain chain) throws IOException {
            Request request = chain.request();
            Request.Builder newRequestBuilder = request.newBuilder();
            String token = getAuthToken();
            if (!TextUtils.isEmpty(token)) {
              newRequestBuilder.addHeader("Authorization", token);
            }

            Request newRequest = newRequestBuilder.build();
            return chain.proceed(newRequest);
          }
        })
        .build();

2. 然后在服务端,我们需要解析客户端传递过来的Token信息并进行校验。这里可以创建一个pythondecorator方法:

def mobile_request(func):
    @functools.wraps(func)
    def wrapped(*args, **kwargs):
        kwargs = kwargs if kwargs or {}
    	if request.headers.get('Authorization'):
    		encrypted_token = request.headers.get('Authorization')
    		isValid, user_info = check_token(encrypted_token) //解析并验证token有效性
    		if not isValid:
    			abort(498) //token无效,返回498状态码
    		user = get_user(user_info)
    		if not user:
    			abort(403) //找不到user,返回403状态码
			kwargs['user_info'] = user_info //成功解析出user_info
    	return func(**kwargs)
    return wrapped		
@app.route("/www/index")
@mobile_request // 使用decorator包装方法
def get_user(**kwargs):
	user_info = kwargs['user_info'] // 取出decorator中封装好的user_info
	return db.get_user(user_info) // 利用user_info进行逻辑处理

3. 最后,请求结果返回到客户端,如果通过监测状态码发现返回结果是与Token相关的error/异常,则表示Token失效,此时我们让用户强制重新登录,生成新Token。这一步仍然可以在上面的interceptor里进行。

OkHttpClient client = new OkHttpClient.Builder()
        .addInterceptor(new Interceptor() {
          @Override
          public Response intercept(Chain chain) throws IOException {
            ... //put token into newRequest
            Response response = chain.proceed(newRequest); // 获取服务端返回结果
            switch(response.code()) {
              case ResponseCode.USER_NOT_FOUND: // 状态码: 403 找不到user
              	eventBus.post(new UserNotFoundEvent()); // 强制logout
              	break;
              case ResponseCode.TOKEN_EXPIRED: // 498 token失效
              	eventBus.post(new TokenExpiredEvent()); // 强制logout
              	break;
              default:
              	break;
            }
            return response;
          }
        })
        .build();

至此,我们完成了Android端和服务端的Token传递、解析和失效处理。

在完善了Token的管理机制后,我们未来的http请求中只要带上这个Token,就可以畅通无阻地去服务端做与自身user相关的各种操作了。

那么,既然Token像家里门禁卡一样,只要拥有就能进入我们服务端并获取这个特定user的所有数据。那也就意味着,一旦攻击者窃取了某个user的Token,那在Token失效前,攻击者随时可以利用这个Token获取这个user的一切信息。

遇到Token被盗,该怎么办呢?

调整Token过期时间

针对Token被盗这种威胁,我们可以缩短Token的过期时间的方法。这样即使一个Token泄漏了,在一段时间后,这个Token也会自动失效。当然这也做会需要用户频繁登录获取新Token;而且失效前的这段时间内,攻击者仍然是可以直接连上服务端随意获取数据的。

Request签名

这种方法也是OAuth推荐的一种方法,其原理是在客户端和服务端统一好某种加密方法和一个密钥,这个密钥同时存储在客户端和服务端。每次客户端准备发起一个请求时,利用这种加密算法和密钥,针对该请求的API和参数进行计算得到一个数,称之为这个Request的签名,然后我们把这个签名放入到Request中。当服务端接收到Request后,就可以利用相同的加密算法和密钥来验证其中签名的真实性。

OkHttpClient client = new OkHttpClient.Builder()
        .addInterceptor(new Interceptor() {
          @Override
          public Response intercept(Chain chain) throws IOException {
            Request request = chain.request();
            String sign = RequestSignUtil.sign(request);
            HttpUrl url = request.url().newBuilder()
        .addQueryParameter("request_sign", sign)
        .build();
            Request newRequest = request.newBuilder().url(url).build();
            return chain.proceed(newRequest);
          }
        })
        .build();

通过对每一个Request签名,可以确保服务端接收到的所有Request都来自我们自己的客户端。即使有人得到了Token想伪造Request,他也不知道如何计算Request签名,从而减小了Token被盗的危害。

当然,每种安全方法都有漏洞,Request签名的方法意味着我们必须在客户端保存好加密算法和密钥,可以通过代码混淆、密钥存储到.so文件等方法来提高破解难度,这里就不再细述了。

小结

上文中,从注册流程开始,介绍了我们在数据安全性方面采取的一些策略和相关实现代码,希望能对读者有帮助。

最后,笔者认为没有完美的安全策略来确保万无一失,不过我们所做的每一步都能够加大被攻击的难度。