为了支持高并发,我把身份证存到了JS文件里

随着时代及互联网的发展,人们对个人隐私越来越重视,但隐私信息泄露及滥用的问题依然屡见不鲜。之前有一份《中国个人信息安全和隐私保护报告》曾抽取100万份调查数据,80%用户遭遇隐私泄露,还比如万豪在18年遭遇3.83亿隐私数据泄露后于2020年3月31日再次爆出520万客户信息泄露。这背后的缘由咱们就不做多讲,除了一些流氓公司的恶意行为,肯定还有很多的商业利益的驱使。今天我们来聊一聊开发人员该如何处理用户隐私,想起半年前在知乎上爆出的某省普通话水平测试查询系统开发人员把身份证直接写在了js里,有网友笑称这才是真正的前后端分离,支撑亿级并发完全不是事

文章开始之前,先抛出一个小问题:除了姓名、身份证、银行卡、手机号外,你觉得还有哪些是用户的敏感信息,需要加密存储?

为了支持高并发,我把身份证存到了JS文件里

什么叫个人信息,哪些又算敏感信息?个人信息该如何存储,又该如何展示?游戏中的兑换码是不是敏感信息?住宿信息是不是敏感信息??作为一名优秀的开发人员,我们不能把目光仅仅聚焦在代码上,不能永远是产品经理或者项目经理让我这么做,还应该掌握所在行业的业务知识,包括法律及政策规范等,提升拓宽我们的业务知识面。

一、用户信息安全规范

关于信息系统建设这一块,国家及行业其实有很多的标准和规范的,比如国家标准全文公开系统(http://openstd.samr.gov.cn/))。关于个人信息,最新的是今年发布的《GB/T 35273-2020 信息安全技术-个人信息安全规范 》,将于2020-10-01正式实施,取代老的标准GB/T 35273-2017。 整个规范文档主要体现了七大原则:权责一致原则、目的明确原则、选择同意原则、最少够用原则、公开透明原则、确保安全原则、主体参与原则
为了支持高并发,我把身份证存到了JS文件里

1.1 ​用户信息、敏感信息定义及判断依据

1.1.1 个人信息

个人信息,personal information。指以电子或者其他方式记录的能够单独或者与其他信息结合识别特定自然人身份或者反映特定自然人活动情况的各种信息。

判定方式

  1. 识别:即从信息到个人,由信息本身的特殊性识别出特定自然人,个人信息应有助于识别出特定个人。
  2. 关联:即从个人到信息,如已知特定自然人,由该特定自然人在其活动中产生的信息(如个人位置信息、个人通话记录、个人浏览记录等)即为个人信息。
    符合上述两种情形之一的信息,均应判定为个人信息。

个人信息举例为了支持高并发,我把身份证存到了JS文件里
:个人信息控制者通过个人信息或其他信息加工处理后形成的信息,例如,用户画像或特征标签,能够单独或者与其他信息结合识别特定自然人身份或者反映特定自然人活动情况的,也属于个人信息。

1.1.2 个人敏感信息

个人敏感信息,personal sensitive information。指一旦泄露、非法提供或滥用可能危害人身和财产安全,极易导致个人名誉、身心健康受到损害或歧视性待遇等的个人信息。通常情况下,14岁以下(含)儿童的个人信息和涉及自然人隐私的信息属于个人敏感信息

判定方式

  1. 泄露:个人信息一旦泄露,将导致个人信息主体及收集、使用个人信息的组织和机构丧失对个人信息的控制能力,造成个人信息扩散范围和用途的不可控。某些个人信息在泄漏后,被以违背个人信息主体意愿的方式直接使用或与其他信息进行关联分析,可能对个人信息主体权益带来重大风险,应判定为个人敏感信息。例如,个人信息主体的身份证复印件被他人用于手机号卡实名登记、银行账户开户办卡等。
  2. 非法提供:某些个人信息仅因在个人信息主体授权同意范围外扩散,即可对个人信息主体权益带来重大风险,应判定为个人敏感信息。例如,性取向、存款信息、传染病史等。
  3. 滥用:某些个人信息在被超出授权合理界限时使用(如变更处理目的、扩大处理范围等),可能对个人信息主体权益带来重大风险,应判定为个人敏感信息。例如,在未取得个人信息主体授权时,将健康信息用于保险公司营销和确定个体保费高低。

个人敏感信息举例
为了支持高并发,我把身份证存到了JS文件里
:个人信息控制者通过个人信息或其他信息加工处理后形成的信息,如一旦泄露、非法提供或滥GB/T 35273—20206用可能危害人身和财产安全,极易导致个人名誉、身心健康受到损害或歧视性待遇等的,属于个人敏感信息。

1.2 ​用户信息存储的注意事项

  1. 个人信息存储时间最小化,超过个人信息存储期限后,应对个人信息进行删除或匿名化处理。
  2. 传输和存储个人敏感信息时,应采用加密等安全措施;采用密码技术时宜遵循密码管理相关国家标准。
  3. 个人生物识别信息应与个人身份信息分开存储
  4. 原则上不应存储原始个人生物识别信息(如样本、图像等),可采取的措施包括但不限于:仅存储个人生物识别信息的摘要信息;在采集终端中直接使用个人生物识别信息实现身份识别、认证等功能; 在使用面部识别特征、指纹、掌纹、虹膜等实现识别身份、认证等功能后删除可提取个人生物识别信息的原始图像。

整个规范文件中,还提到了用户信息的使用、展示、第三方接入、安全管理等等,有兴趣的小伙伴可以自定搜索了解一下。

二、​框架技术实现

2.1 用户敏感信息自动加解密

正如第一章节提到的,用户的真实姓名、手机号、银行卡号、包括住宿等敏感信息需要加密存储到数据库中,业务正常使用的时候再转化为明文数据。从技术实现角度来看,无非就是新增、编辑时进行加密,查询时解密,这样一个个操作起来还是比较low的,而且很可能哪天新增了一个方法又忘记加解密了。所以大部分会通过框架来实现,实现的原理无外乎反射机器+拦截器。接下来以Mybatis为例,原理如下图,具体可参考:https://blog.csdn.net/weixin_39494923/article/details/91534658
为了支持高并发,我把身份证存到了JS文件里

2.1.1 通过Interceptor实现数据的自动加解密

Mybatis默认提供了一个拦截器接口Interceptor,大部分Mybatis的增强工具都是通过该接口实现的。如果要实现自定义的拦截器,只需要实现 org.apache.ibatis.plugin.Interceptor 接口,该接口有三个方法:

Object intercept(Invocation invocation) throws Throwable;

Object plugin(Object target);

void setProperties(Properties properties);

首先以自定义一个注解@Crypt,作用在字段上,用于告诉拦截器那个字段需要加解密。

@Target({ ElementType.FIELD,ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface Crypt {

}

接下来添加一个自定义拦截器,selelct方法时进行解密,update和add方法时进行加密。

@Intercepts({ @Signature(type = Executor.class, method = "update", args = { MappedStatement.class, Object.class, }),
        @Signature(type = Executor.class, method = "query", args = { MappedStatement.class, Object.class,
                RowBounds.class, ResultHandler.class }),
        @Signature(type = ResultSetHandler.class, method = "handleResultSets", args = { Statement.class }) })
public class CryptInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object[] args = invocation.getArgs();
        if (args.length <= 0 || invocation.getMethod() == null || args[0] == null) {
            return invocation.proceed();
        }

        String methodName = invocation.getMethod().getName();
        if ("update".equals(methodName) && args[1] != null) {
            return this.interceptUpdate(invocation);
        } else if ("query".equals(methodName) && args[1] != null) {
            return this.interceptQuery(invocation);
        } else if ("handleResultSets".equals(methodName)) {
            return this.interceptHandleResultSets(invocation);
        }
        return invocation.proceed();
    }

    private Object interceptHandleResultSets(Invocation invocation) throws Throwable {
        Object resultCollection = invocation.proceed();
        // 略 将resultCollection的对象中有@Crypt注解的Feild进行解密
        return newObject;
    }

    private Object interceptUpdate(Invocation invocation) throws Throwable {
        Object[] args = invocation.getArgs();
        Object args1Obj = args[1];
        // 略 将args1Obj的对象进行加密
        args[1] = newObject;
        return invocation.proceed();
    }
    
    private Object interceptQuery(Invocation invocation) throws Throwable {
        Object[] args = invocation.getArgs();
        Object condition = args[1];
        // 略 将condition对象进行解密
        args[1] = newObject;
        return invocation.proceed();
    }    
}

2.1.2 通过BaseTypeHandler实现数据的自动加解密

一般情况下不会通过Interceptor接口对Mybatis的请求进行拦截,除非类似于“读写分离”这样的一些复杂的需求。参见上面的mybatis的执行过程,我们发现最后一步调用了TypeHander,这个类的作用就是把数据库与实体之间进行类型转换,比如把MySql的varchar转为Java的Long,把Java的Integer转为Mysql的int,所以我们可以借助于BaseTypeHandler类。

@Component
@Alias("CryptHandler")
@MappedTypes(value = {Crypt.class})
public class EncryptHandler extends BaseTypeHandler {

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, Object parameter, JdbcType jdbcType)
        throws SQLException {
        ps.setString(i, encrypt(parameter.toString()));
    }

    @Override
    public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
        String columnValue = rs.getString(columnName);
        return decrypt(columnValue);
    }

    @Override
    public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        String columnValue = rs.getString(columnIndex);
        return decrypt(columnValue);
    }

    @Override
    public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        String columnValue = cs.getString(columnIndex);
        return decrypt(columnValue);
    }

    private String encrypt(String parameter) {
        // 加密
        return parameter;
    }

    private String decrypt(String columnValue) {
        // 解密
        return columnValue;
    }
}

完整 代码见上面,不做多讲。接下来需要告诉Mybatis哪些字段需要加解密,为了简化书写,定义一个类Crypt重命名为crypt,上面的类EncryptHandler也重命名为EncryptHandler

@Alias("crypt")
public final class Crypt {

}

上面的两个类都放在cn.itmds.plugin目录下,配置yml文件告诉Mybatis读取重命名的配置

mybatis:
  type-aliases-Package: cn.itmds.plugin.dbcrypt
 

接下来,假设有一张member表的realname(真实姓名)字段需要加解密,写起来就很简单了:

 <sql id="memberConditionSql">
        <where>
            <if test="id != null">and id = #id}</if>
            <!--这个地方只需要指定javaType=crypt,如果上面没有重命名,这个地方需要写成javaType= cn.itmds.plugin.dbcrypt.Crypt,写起来比较麻烦 -->
            <if test=realName != null">and real_name = #{realName,javaType=crypt}</if>
        </where>
    </sql>
    <resultMap id="memberDOResultMap" type="MemberDO">
        <!--这个地方只需要指定typeHandler=CryptHandler,如果上面没有重命名,这个地方需要写成javaType= cn.itmds.plugin.dbcrypt.CryptHandler,写起来比较麻烦 -->
        <!--另外,只需要将需要解密的字段写到这个resultMap里即可,不需要写全部的字段,其他字段系统会自动映射为MemberDO -->
        <result column="phone" property="phone" typeHandler="CryptHandler"/>
    </resultMap>

2.1.3 MybatisPlus实现数据的自动加解密

MyBatis-Plus(简称 MP)是一个 MyBatis 的增强工具,在 MyBatis的基础上只做增强不做改变,为简化开发、提高效率而生。

MyBatis-Plus只需简单配置,即可快速进行 CRUD 操作,从而节省大量时间。而且还支持Lambda表达式,通过对象来操作sql等,所以现在使用的人越来越多。那么它如何来实现数据的自动加解密呢,超级简单。实现原理和2.1.2一样,也是通过BaseTypeHandler来实现。

1、增加@TableField(typeHandler = EncryptHandler.class),其中EncryptHandler就是2.1.2定义的EncryptHandler.java,此时新增、修改时就实现了自动加密。
2、在@TableName上设置autoResultMap = true,此时就实现了返回值的自动解密。

Done!示例:

@Data
@TableName(value = "user_info",autoResultMap = true)
public class UserPO {

    /**  */
    @TableId(type = IdType.AUTO)
    private Long id;

    /** 真实姓名 */
    @TableField(typeHandler = EncryptHandler.class)
    private String realName;
}

2.2 日志文件自动过滤用户敏感信息

为了便于开发调试及产线问题定位,开发框架基本都会定义日志拦截器,对所有的controller层和service层的方法进行拦截,打印详细等入参、出参。在2.1中我们提到了用户的敏感信息的加解密是在dao底层自动完成的,所以也就导致了日志中还会打印了用户的敏感信息,那么此时该如何处理呢?接下来提供一个完整的案例。

  1. 定义一个注解@ServiceLog,可以作用在类上或者方法上。提供一个参数:ignore,默认为false。如果为true,表示该方法不需要打印日志。比如某一个类里有很多个方法需要日志,但其中某个方法是用于文件上传的或者定时任务每秒都会执行1次,这些场景下不需要打印日志,则可以设置ignore=true。
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ServiceLog {

    boolean ignore() default false;
}
  1. 定义一个全局拦截器,打印入参、出参日志,在这里使用的是FastJson将对象转化为字符串。
@Aspect
@Component
public class ServiceLogAspect {

    @Around("@within(cn.itmds.log.ServiceLog)")
    protected Object aroundJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature)joinPoint.getSignature();
        Method method = signature.getMethod();
        ServiceLog serviceLog = method.getAnnotation(ServiceLog.class);
        if (null != serviceLog && serviceLog.ignore()) {
            return joinPoint.proceed();
        }
        long beginTime = System.currentTimeMillis();
        Class clazz = joinPoint.getTarget().getClass();
        String methodName = clazz.getSimpleName() + "." + method.getName();
        // 打印请求所有的入参
        log.info("Begin|{}|{}", methodName, jsonString(joinPoint.getArgs()));

        Object result = null;
        try {
            result = joinPoint.proceed();
        } finally {
            // 打印所有的出参
            log.info("End|{}|{}ms|{}", methodName, System.currentTimeMillis(),
                 - beginTime, jsonString(result));
        }
        return result;
    }
}
  1. 增加一个配置项,定义需要过滤的敏感信息,比如真实姓名、手机号、身份证、密码等
logging:
  sensitiveChars: realName,phoneNumber,idCard,mail,password
  1. 接下来,我们可以利用FastJSON的过滤器特性来实现日志的过滤。
    private ValueFilter valueFilter = (object, name, value) -> {
        if (null == value || "".equals(value)) {
            return value;
        }

        if (value instanceof byte[]) {
            // 如果是byte字节,直接打印长度
            return "byte length:" + ((byte[])value).length;
        } else if (value instanceof String) {
            // 在该方法里检查name,如果name包含我们配置的敏感信息,则将value设置为加*隐藏。
            return stringValueProcess(name, (String)value);
        } else {
            return value;
        }
    };

在第二步拦截器的方法aroundJoinPoint中,对象转化为String时,使用FastJSON的过滤器。

    protected String jsonString(Object object) {
        return JSON.toJSONString(object, valueFilter);
    }
  1. Controller层同样,拦截所有的controller目录下的文件即可。
@Around("execution(public * cn.itmds.controller..*(..) )")

Controller通过该方法实现时要注意,http请求和response请求有些字段是无法序列化的,所以务必要进行过滤。

public static <T> Stream<T> streamOf(T[] array) {
        return ArrayUtils.isEmpty(array) ? Stream.empty() : Arrays.asList(array).stream();
    }

//... 拦截器的方法中增加过滤
 List<Object> logArgs = (List)streamOf(args).filter((arg) -> {
                return !(arg instanceof HttpServletRequest) && !(arg instanceof HttpServletResponse);
            }).collect(Collectors.toList());
// 打印请求所有的入参
log.info("Begin|{}|{}", methodName, jsonString(logArgs));

2.3 密码加密和《密码法》

关于密码,国家也是有一部《密码法》的,最近好像也在推广宣传。当然我们平时常说的用户名“密码”只是“口令”,并不是密码法中的“密码”。《密码法》中的密码使用范围包含二代身份证、电子签名、增值税发票密码区之类的,具体大家可以去看看全文,不做多讲。
为了支持高并发,我把身份证存到了JS文件里

2.3.1 密码加密的注意事项

现在的开发人员基本都具备一定的安全知识,很少有明文存储密码的了,甚至直接md5的也很少,大部分都开始采用sha1,sha256了,也有一些公司开始使用用Argon2

Argon2 是一种慢哈希函数,在 2015 年获得 Password Hashing Competition 冠军,利用大量内存计算抵御GPU 和其他定制硬件的破解,提高哈希结果的安全性。

这里主要讲几点:

  1. 每一个密码都要加上不同的盐,确保相同的密码也产生不同的hash。比如两个人的密码都是abcd1234,生成的hash一定要是不同的。
  2. 不要使用普通的随机算法生成盐,一定要使用CSPRNG(Cryptographically Secure Pseudo-Random Number Generator);对应java就是Java.security.SecureRandom,对应C/C++ CryptGenRandom。
  3. 有些系统使用用户的id、手机号等来作为盐加密密码,这其实不符合盐的生成规则要求。但对于一般性的安全性要求并不是那么高的网站,也基本能用。

2.3.2 使用BCrypt实现密码加密

Bcrypt是一个跨平台的文件加密工具,SpringSecurity默认使用了该算法。如果项目中没有依然SpringSecurity,也可以单独引入jar包。 bcrypt算法与md5/sha算法有一个很大的区别,就是每次生成的hash值都是不同的,不需要我们自行指定盐。加密后的字符长度比较长,有60位,数据库字段设计时务必要注意。示例如下:

    public static void main(String[] args) {
        BCryptPasswordEncoder bcrypt = new BCryptPasswordEncoder();
        String pwd = "abcd1234";
        for (int i = 0; i < 5; i++) {
            String encodePwd = bcrypt.encode(pwd);
            boolean result = bcrypt.matches(pwd, encodePwd);
            System.out.println(encodePwd + "|" + result);
        }
    }

为了支持高并发,我把身份证存到了JS文件里
加密后的字符串值组成

  • $是分割符,无意义;
  • 2a是bcrypt加密版本号;
  • 10是cost的值;
  • 后面的字符串中,前22位是salt值;再然后的字符串就是密码的密文了。

有兴趣的可以看下源码

public static String gensalt(int log_rounds, SecureRandom random) {
        if (log_rounds < MIN_LOG_ROUNDS || log_rounds > MAX_LOG_ROUNDS) {
            throw new IllegalArgumentException("Bad number of rounds");
        }
        StringBuilder rs = new StringBuilder();
        byte rnd[] = new byte[BCRYPT_SALT_LEN];

        random.nextBytes(rnd);

        rs.append("$2a$");
        if (log_rounds < 10) {
            rs.append("0");
        }
        rs.append(log_rounds);
        rs.append("$");
        encode_base64(rnd, rnd.length, rs);
        return rs.toString();
    }

2.3.3 Dropbox密码加密存储防范

Dropbox是提供文件在线存储的著名厂商,曾在其官方技术博客发表名为《How Dropbox securely stores your passwords》的文章,讲述了他们的用户密码加密存储方案。
为了支持高并发,我把身份证存到了JS文件里

  1. 首先使用sha512,将用户密码归一化为64字节hash值。因为两个原因:一个是Bcrypt算对输入敏感,如果用户输入的密码较长,可能导致Bcrypt计算过慢从而影响响应时间;另一个是有些Bcrypt算法的实现会将长输入直接截断为72字节,从信息论的角度讲,这导致用户信息的熵变小;
  2. 然后使用Bcrypt算法。选择Bcrypt的原因,是Dropbox的工程师对这个算法更熟悉调优更有经验,参数选择的标准,是Dropbox的线上API服务器可以在100ms左右的时间可计算出结果。另外,关于Bcrypt和Scrypt哪个算法更优,密码学家也没有定论。同时,Dropbox也在关注密码hash算法新秀Argon2,并表示会在合适的时机引入;
  3. 最后使用AES加密。因为Bcrypt不是完美的算法,所以Dropbox使用AES和全局密钥进一步降低密码被破解的风险,为了防止密钥泄露,Dropbox采用了专用的密钥保存硬件。Dropbox还提到了最后使用AES加密的另一个好处,即密钥可定时更换,以降低用户信息/密钥泄露带来的风险。

用户隐私保护,远不是开发人员加解密这么简单,还需要运营、运维团队各方面的配合,任重而道远!

【人总要给自己留一些隐私的空间,就像你总是会站在你的影子前挡住了光的视线】
People always want to give yourself some privacy space, just like you will always be standing in front of the shadow of you blocking the line of sight of the light.

参考:
https://www.cnblogs.com/xinzhao/p/6035847.html
https://blog.csdn.net/weixin_39494923/article/details/91534658


公众号:码大叔
资深程序员、架构师技术社区
关注架构设计、大数据、微服务、技术管理。

上一篇:【百川云栖分享】承渊:助力移动App从0到N—— 解读阿里百川移动开放平台


下一篇:SpringCloud第二代实战系列:使用Nacos实现服务注册与发现