Java岗大厂面试百日冲刺【Day36】—— 实战那些事儿1
本文已获得原作者 _陈哈哈 授权并经过重新整理规划后发布。
本栏目Java开发岗高频面试题主要出自以下各技术栈:Java基础知识、集合容器、并发编程、JVM、Spring全家桶、MyBatis等ORMapping框架、MySQL数据库、Redis缓存、RabbitMQ消息队列、Linux操作技巧等。
面试题1:你们是怎样保存用户密码等敏感数据的?
我们知道,用户名、密码、身份证等都属于用户敏感信息,其中最敏感的数据恐怕就是用户的密码了。黑客一旦窃取了用户密码,就可以登录进用户的账号,消耗其资产、发布不良信息等;更可怕的是,有些用户至始至终都是使用一套密码,密码一旦泄露,就可以被黑客通过撞库来登录全网各大平台,嘿嘿嘿。
为了防止密码泄露,最重要的原则是不要在数据库保存用户原始密码。
大家经常说,不要明文保存用户密码,应该把密码通过 MD5 加密后保存。这的确是一个正确的方向,但这个说法并不准确。
首先,MD5 其实不是真正的加密算法。所谓加密算法,是可以使用密钥把明文加密为密文,随后还可以使用密钥解密出明文,是双向的。而 MD5 是散列、哈希算法或者摘要算法。不管多长的数据,使用 MD5 运算后得到的都是固定长度的摘要信息或指纹信息,无法再解密为原始数据。所以,MD5 是单向的。最重要的是,仅仅使用 MD5 对密码进行摘要,并不安全。
比如,使用如下代码在保持用户信息时,对密码进行了 MD5 计算:
UserData userData = new UserData();
userData.setId(1L);
userData.setName(name);
//密码字段使用MD5哈希后保存
userData.setPassword(DigestUtils.md5Hex(password));
return userRepository.save(userData);
通过输出,可以看到密码是 32 位的 MD5:
"password": "325a2cc052914ceeb8c19016c091d2ac"
然后,再去破解网站试一下这个 MD5,就可以得到原始密码是 salt,也就知道了盐值是 salt:
其实,知道盐是什么没什么关系,关键的是我们是在代码里写死了盐,并且盐很短、所有用户都是这个盐。这么做就会有三个问题:
- 因为盐太短、太简单了,如果用户原始密码也很简单,那么整个拼起来的密码也很短,这样一般的 MD5 破解网站都可以直接解密这个 MD5,除去盐就是原始密码了。
- 相同的盐,意味着使用相同密码的用户 MD5 值是一样的,知道了一个用户的密码就可能知道了多个。
- 黑客也可以使用这个盐来构建一张彩虹表,也就是字典表,虽然会花不少代价,但是一旦构建完成,所有人的密码都可以被破解。
所以,最好是每一个密码都有独立的盐,并且盐要长一点,比如超过 20 位。
第二,虽然说每个人的盐最好不同,但也不建议将一部分用户数据作为盐。比如,使用用户名作为盐:
userData.setPassword(DigestUtils.md5Hex(name + password));
如果世界上所有的系统都是按照这个方案来保存密码,那么 root、admin 这样的用户使用再复杂的密码也总有一天会被破解,因为黑客们完全可以针对这些常用用户名来做彩虹表。所以,盐最好是随机的值,并且是全球唯一的,意味着全球不可能有现成的彩虹表给你用。
正确的做法是,使用全球唯一的、和用户无关的、足够长的随机值作为盐。比如,可以使用 UUID 作为盐,把盐一起保存到数据库中:
userData.setSalt(UUID.randomUUID().toString());
userData.setPassword(DigestUtils.md5Hex(userData.getSalt() + password));
并且每次用户修改密码的时候都重新计算盐,重新保存新的密码。
需要注意的是,这么做虽然黑客已经很难通过彩虹表来破解密码了,但是仍然有可能暴力破解密码,也就是对于同一个用户名使用常见的密码逐一尝试登录。因此,除了做好密码哈希保存的工作外,我们还要建设一套完善的安全防御机制,在感知到暴力破解危害的时候,开启短信验证、图形验证码、账号暂时锁定等防御机制来抵御暴力破解。
那么姓名和身份证又是怎么保存的?
我们把姓名和身份证,叫做二要素。
现在互联网非常发达,很多服务都可以在网上办理,很多网站仅仅依靠二要素来确认你是谁。所以,二要素是比较敏感的数据,如果在数据库中明文保存,那么数据库被攻破后,黑客就可能拿到大量的二要素信息。如果这些二要素被用来申请贷款等,后果不堪设想。
之前我们提到的单向散列算法(MD5),显然不适合用来加密保存二要素,因为数据无法解密。这个时候,我们需要选择真正的加密算法。可供选择的算法,包括对称加密和非对称加密算法两类。
对称加密算法
:是使用相同的密钥进行加密和解密。使用对称加密算法来加密双方的通信的话,双方需要先约定一个密钥,加密方才能加密,接收方才能解密。如果密钥在发送的时候被窃取,那么加密就是白忙一场。因此,这种加密方式的特点是,加密速度比较快,但是密钥传输分发有泄露风险。
非对称加密算法
:或者叫公钥密码算法。公钥密码是由一对密钥对构成的,使用公钥或者说加密密钥来加密,使用私钥或者说解密密钥来解密,公钥可以任意公开,私钥不能公开。使用非对称加密的话,通信双方可以仅分享公钥用于加密,加密后的数据没有私钥无法解密。因此,这种加密方式的特点是,加密速度比较慢,但是解决了密钥的配送分发安全问题。
但是,对于保存敏感信息的场景来说,加密和解密都是我们的服务端程序,不太需要考虑密钥的分发安全性,也就是说使用非对称加密算法没有太大的意义。我们一般会使用对称加密算法来加密数据。常见的对称加密算法有:DES、3DES 和 AES
。
面试题2:怎么控制用户请求的幂等性的?
幂等性:对于同一笔业务操作,不管调用多少次,得到的结果都是一样的。
当然,有些操作是天然幂等的,如:
- 查询操作:查询一次和查询多次,在数据不变的情况下,查询结果是一样的。select是天然的幂等操作;
- 删除操作:删除操作也是幂等的,删除一次和多次删除都是把数据删除。
后台控制幂等性的几种途径:
1. 设置唯一索引
:防止新增脏数据
比如支付宝的资金账户,支付宝也有用户账户,每个用户只能有一个资金账户,怎么防止给用户创建资金账户多个,那么给资金账户表中的用户ID加唯一索引,所以一个用户新增成功一个资金账户记录。要点:唯一索引或唯一组合索引来防止新增数据存在脏数据(当表存在唯一索引,并发时新增报错时,再查询一次就可以了,数据应该已经存在了,返回结果即可);
2. token机制
:防止页面重复提交
原理上通过session token来实现的(也可以通过redis来实现)。当客户端请求页面时,服务器会生成一个随机数Token,并且将Token放置到session当中,然后将Token发给客户端(一般通过构造hidden表单)。下次客户端提交请求时,Token会随着表单一起提交到服务器端。
服务器端第一次验证相同过后,会将session中的Token值更新下,若用户重复提交,第二次的验证判断将失败,因为用户提交的表单中的Token没变,但服务器端session中Token已经改变了。
3. 悲观锁
: 获取数据的时候加锁获取。
select * from table_xxx where id='xxx' for update;
注意:id字段一定是主键或者唯一索引,不然是锁表,会死人的;悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,根据实际情况选用;
4. 乐观锁
: 乐观锁只是在更新数据那一刻锁表,其他时间不锁表,所以相对于悲观锁,效率更高。乐观锁的实现方式多种多样,可以通过version或者其他状态条件判断:
- 通过版本号实现
update table_xxx set name=#name#,version=version+1 where version=#version#;
- 通过条件限制
update table_xxx set avai_amount=avai_amount where avai_amount >= 0
5. 分布式锁
如果是分布式系统,构建全局唯一索引比较困难,例如唯一性的字段没法确定,这时候可以引入分布式锁,通过第三方的系统(redis或zookeeper),在业务系统插入数据或者更新数据,获取分布式锁,然后做操作,之后释放锁,这样其实是把多线程并发的锁的思路,引入多多个系统,也就是分布式系统中得解决思路。
要点:某个长流程处理过程要求不能并发执行,可以在流程执行之前根据某个标志(用户ID+后缀等)获取分布式锁,其他流程执行时获取锁就会失败,也就是同一时间该流程只能有一个能执行成功,执行完成后,释放分布式锁(分布式锁要第三方系统提供);
面试题3:你们是如何预防SQL注入问题的?
SQL注入攻击的总体思路
- 寻找到SQL注入的位置
- 判断服务器类型和后台数据库类型
- 针对不通的服务器和数据库特点进行SQL注入攻击
预防方式:
1、PreparedStatement(简单有效)
采用预编译语句集,它内置了处理SQL注入的能力,只要使用它的setXXX方法传值即可。
sql注入只对sql语句的准备(编译)过程有破坏作用,而PreparedStatement在执行阶段只是把输入串作为数据处理,不再对sql语句进行解析,因此也就避免了sql注入问题。
2、使用正则表达式过滤传入的参数
要引入的包:
import java.util.regex.*;
正则表达式:
private String CHECKSQL = “^(.+)\\sand\\s(.+)|(.+)\\sor(.+)\\s$”;
判断是否匹配:
Pattern.matches(CHECKSQL,targerStr);
下面是常用来过滤参数是否存在SQL注入的正则表达式:
- 检测SQL meta-characters的正则表达式 :
/(\%27)|(\')|(\-\-)|(\%23)|(#)/ix
- 修正检测SQL meta-characters的正则表达式 :
/((\%3D)|(=))[^\n]*((\%27)|(\')|(\-\-)|(\%3B)|(:))/i
- 典型的SQL 注入攻击的正则表达式 :
/\w*((\%27)|(\'))((\%6F)|o|(\%4F))((\%72)|r|(\%52))/ix
- 检测SQL注入,UNION查询关键字的正则表达式 :
/((\%27)|(\'))union/ix(\%27)|(\')
3.使用正则表达式过滤传入的URL
比较通用的一个方法,jsp中调用该函数检查是否包函非法字符,防止SQL从URL注入:
(||之间的参数可以根据自己程序的需要添加)
public static boolean sql_inj(String str){
String inj_str = "'|and|exec|insert|select|delete|update|count|*|%|chr|mid|master|truncate|char|declare|;|or|-|+|,";
String inj_stra[] = split(inj_str,"|");
for (int i=0 ; i < inj_stra.length ; i++ ){
if (str.indexOf(inj_stra[i])&&=0){
return true;
}
}
return false;
}