重要链接:
「系列文章目录」
前言
距离上次写文章已经快一个月了。突然感觉很对不起老读者,可能原本打算用它一两周做个小项目练练手,结果半年过去了这个沙雕作者居然还没更完。这里我诚挚地给各位道个歉,我在这儿立个 flag,只要不加班,以后我保证不会拖更超过两周。
不过说实话,不拖更还真感受不到这么多人等着我。读者大人们如果感到文章还算有一些价值,不妨点个赞、收个藏、转个发之类,这对我也是一种实打实的鼓励。
另外随着阅读量的增加,问我问题的读者也越来越多了,过去我基本上只要有时间就会帮忙看一下,但是现在真的应对不过来了,所以只能选择性回答啦。像 “为什么我运行xx会报错啊” 之类的问题,最好先有个思路再提问,有的同学甚至连错误信息都不说,以为我掐指一算就能得出结论,那我也太 six 了。还有其实我也挺势利的,一般要是一看诶这哥们儿也没给我点赞也没关注我,那一般就直接忽略啦。在此基础上还说话不好听的,我一怒之下就直接怼回去了,谁还不是网络暴民了咋的。刚好手上有个例子,给大家看一下:
讲道理这种情况下即使我发现他说的对,肯定评论一删悄悄改掉,这叫知错改错不认错。鉴于再往下发展就要口吐芬芳了,我还是选择了把这个评论删除,我的博客我做主嘛。但现实生活中可不能这么横,强梁者死,柔弱者生。
站在对方的立场上总结两点经验:提问的艺术是准备充足,提意见的艺术是尽量不提意见,尤其是在别人没邀请你提的时候。
(还有登录离子烫是啥,知道的读者麻烦给我解读一下,这个词感觉土潮土潮的,是我读书少才没见过吗?)
言归正传,拖更也不能白拖,这篇文章我打算拿出上万字的篇幅详细讲解如下两方面内容:
- 用户信息加密思路
- 使用 Shiro 完成加密与认证功能
同时也对 Shiro 的一些概念做出了讲解,希望大家可以通过这个简单的功能理解这个框架。
这篇与下一篇文章,是之后实现各种权限控制的基础,后面要做的基本上就是增删改查了。
一、用户信息加密
之前我们的用户信息都是明文存储在数据库中的,这样做有两大弊端:
- 第一,不安全,之前有很多应用被脱裤后用户密码全网流传,救都救不回来,就是因为采用了这种设计
- 第二,你想啊,用户肯定也不愿意让咱们知道他的密码啊,所以咱就得想个办法忽悠一下他们,跟他们说诶你看你密码我们这儿也见不着,而且还破解不了,所以你不用担心我们能上你号(我上不了你号,但是你号上有啥我都知道。。。)
- 第三,如果用户有在各个应用使用相同密码的习惯,那完了,一个地方密码被盗一串号都没了。
所以,我们要对部分用户信息进行加密,主要是用于 验证 的 敏感 的信息,比如密码,而且这种加密最好是不可逆的,即明文密码只有用户知道,我们算不出来。
1.hash 算法
鉴于很多同学对 hash 算法的认识有误区,这里我还是简单介绍一下。内容经过我密码学专业的室友不权威认证,可靠程度三星半,还算得一看。
hash 算法(散列算法、摘要算法)即把任意长度的输入映射为固定长度的输出,比如密码 Evanniubi 变成五位的输出 kchpl,这种算法不可逆,且存在信息损失,虽然随着时间推移,出现了字典法、彩虹表法等优化手段,但本质上想要破解还是靠穷举与瞎蒙,而且对于复杂密码来说,破解成本极高。
不过,由于大部分人习惯使用的密码并不复杂,所以还是存在一定的风险,我随手百度一下,结果就像下面这样:
想找到一个破解工具十分容易,加上各路人马的添油加醋,慢慢地很多人就以为 hash 不安全不靠谱,甚至对用它的人表示不屑。其实,算法本身是足够安全的,是你设置简单密码的习惯害了你。当然,随着计算机算力的提升,破解固定长度 hash 值所需的时间也会不断减少,但 MD5 不行了我们可以用 sha256,sha256 不行了还有 sha512,想要多少位就有多少位,总能让黑客老弟吃不消 。
2.加盐加密
加盐,是提高 hash 算法的安全性的一个常用手段。我猜选这个词的哥们儿想表达的意思就是 “我再给你加点料,看你还怎么破解”。其实本质就是在密码后面加一段随机的字符串,然后再 hash。下面是加盐加密与验证的逻辑:
- 用户注册时,输入用户名密码(明文),向后台发送请求
- 后台将密码加上随机生成的盐并 hash,再将 hash 后的值作为密码存入数据库,盐也作为单独的字段存起来
- 用户登录时,输入用户名密码(明文),向后台发送请求
- 后台根据用户名查询出盐,和密码组合并 hash,将得到的值与数据库中存储的密码比对,若一致则通过验证
加盐为什么能提高安全性?
我们知道一个 hash 值(输出)可以对应无数输入,如果不加盐,找到一个和明文密码 hash 结果相同的输入相对容易,但在有盐的情况下,如果不知道盐,找到这种输入的难度就是炸裂性增长。
当然,如果别人有办法拿到数据库中以 hash 值存储的密码,拿到盐的信息也是有可能的,但是由于不同用户盐不同,所以即使有很多用户使用了相同的密码,存储在数据库里的 hash 值也不同,试图窃取信息的黑客只能一个一个的去算,这才是加盐最大的意义所在。
在加盐的基础上,我们还可以设置 hash 的迭代次数,即进行多次 hash,进一步加大破解难度,但这就属于小打小闹了。但有的系统能搞上千次迭代,牛批,我只能说有钱,我可买不起这么多服务器。。。
3.核心代码
之前也说过,进入第二阶段以后我就不再赘述无关紧要的步骤了,但我暂时还是尽量多放一些代码,让大家有个适应的过程。当然,这些代码肯定不是简单地赋值粘贴就能用的,如果处理不了出现的问题,说明前面的内容掌握的不太牢靠,还是不要眼高手低,多自己撸点代码吧。
加密在注册与认证中都有体现,但考虑到认证要用到 shiro,所以先讲在注册中的实现。
首先,我们要在数据库的 user 表中添加 salt 字段,并相应地在 pojo 中添加 salt 属性与 get、set 方法。
接着,开发 register
方法,代码如下:
@PostMapping("api/register")
@ResponseBody
public Result register(@RequestBody User user) {
String username = user.getUsername();
String password = user.getPassword();
username = HtmlUtils.htmlEscape(username);
user.setUsername(username);
boolean exist = userService.isExist(username);
if (exist) {
String message = "用户名已被使用";
return ResultFactory.buildFailResult(message);
}
// 生成盐,默认长度 16 位
String salt = new SecureRandomNumberGenerator().nextBytes().toString();
// 设置 hash 算法迭代次数
int times = 2;
// 得到 hash 后的密码
String encodedPassword = new SimpleHash("md5", password, salt, times).toString();
// 存储用户信息,包括 salt 与 hash 后的密码
user.setSalt(salt);
user.setPassword(encodedPassword);
userService.add(user);
return ResultFactory.buildSuccessResult(user);
}
注意生成 salt 的方法 SecureRandomNumberGenerator().nextBytes().toString()
,这玩意儿先生成了随机的 byte 数组,又转换成了字符串类型的 base64 编码并返回。base64 没什么意义,这里绕这么一下,只是因为比起字节数组,很多人更习惯于用字符串。
为了实现注册,前端再设计一个注册页面,可以和登录页面保持风格统一。可以参考以下代码:
<template>
<body id="paper">
<el-form class="login-container" label-position="left"
label-width="0px" v-loading="loading">
<h3 class="login_title">用户注册</h3>
<el-form-item>
<el-input type="text" v-model="loginForm.username"
auto-complete="off" placeholder="账号"></el-input>
</el-form-item>
<el-form-item>
<el-input type="password" v-model="loginForm.password"
auto-complete="off" placeholder="密码"></el-input>
</el-form-item>
<el-form-item style="width: 100%">
<el-button type="primary" style="width: 40%;background: #505458;border: none" v-on:click="register">注册</el-button>
</el-form-item>
</el-form>
</body>
</template>
<script>
export default{
data () {
return {
checked: true,
loginForm: {
username: '',
password: ''
},
loading: false
}
},
methods: {
register () {
var _this = this
this.$axios
.post('/register', {
username: this.loginForm.username,
password: this.loginForm.password
})
.then(resp => {
if (resp.data.code === 200) {
this.$alert('注册成功', '提示', {
confirmButtonText: '确定'
})
_this.$router.replace('/login')
} else {
this.$alert(resp.data.message, '提示', {
confirmButtonText: '确定'
})
}
})
.catch(failResponse => {})
}
}
}
</script>
<style>
#paper {
background:url("../assets/img/bg/eva1.jpg") no-repeat;
background-position: center;
height: 100%;
width: 100%;
background-size: cover;
position: fixed;
}
body{
margin: -5px 0px;
}
.login-container {
border-radius: 15px;
background-clip: padding-box;
margin: 90px auto;
width: 350px;
padding: 35px 35px 15px 35px;
background: #fff;
border: 1px solid #eaeaea;
box-shadow: 0 0 25px #cac6c6;
}
.login_title {
margin: 0px auto 40px auto;
text-align: center;
color: #505458;
}
</style>
这样就完成了注册时的加密。
二、使用 Shiro 认证登录
完成上一步后,你可能会发现新注册的账户登录不上去,这是因为我们的登录方法还没有修改。过去我们简单粗暴地采取明文比对的方法进行验证,而现在一切都将改变,我们将使用 Shiro 来完成这个壮举,oh my god,当你深入了解 Shiro 在背后为你做的那些动人的事情之后,你会不禁感叹,这破玩意儿,好好起名字不行吗。。。
1.Shiro 核心概念
关于 Shiro,需要理解三个核心概念:Subject、SecurityManager 和 Realms。
这里我特意去查了官方文档,因为感觉市面上很多解读不太靠谱。所谓戏说不是胡说,改编不是乱编,一千个读者心中只能有一个 Shiro,那就是我讲的 Shiro。
The word Subject is a security term that basically means “the currently executing user”. It’s just not called a ‘User’ because the word ‘User’ is usually associated with a human being. In the security world, the term ‘Subject’ can mean a human being, but also a 3rd party process, daemon account, or anything similar. It simply means ‘the thing that is currently interacting with the software’. For most intents and purposes though, you can think of this as Shiro’s ‘User’ concept.
Subject: “现在在与软件交互的东西”,这个东西可能是你是我,可能是第三方进程。说白了就是穿了马甲的用户类,负责存储与修改当前用户的信息和状态。
之后你会看到,使用 Shiro 实现我们所设计的各种功能,实际上就是在调用 Subject 的 API。
The Subject’s ‘behind the scenes’ counterpart is the SecurityManager. While the Subject represents security operations for the current user, the SecurityManager manages security operations for all users. It is the heart of Shiro’s architecture and acts as a sort of ‘umbrella’ object that references many internally nested security components that form an object graph. However, once the SecurityManager and its internal object graph is configured, it is usually left alone and application developers spend almost all of their time with the Subject API.
SecurityManager: Subject 背后的女人,安全相关的操作实际上是由她管理的。只用在项目中配置一次,就可以忘掉她了。
A Realm acts as the ‘bridge’ or ‘connector’ between Shiro and your application’s security data. That is, when it comes time to actually interact with security-related data like user accounts to perform authentication (login) and authorization (access control), Shiro looks up many of these things from one or more Realms configured for an application.
Realm: 是 Shiro 和安全相关数据(比如用户信息)的桥梁,也就是说,Realm 负责从数据源中获取数据并加工后传给 SecurityManager。
我们可以通过配置使用特定的 Realm 替代 DAO,和 JPA 类似,Realm 获取数据的方法被封装了起来,但是数据库中的表名、字段等需要与源码预定义的查询保持一致,所以在我们的项目中获取数据的功能仍旧可以交给 JPA 完成,Realm 只负责加工并传递这些数据。
除了上述三种概念,还有四大功能——Authentication(认证)、Authorization(授权)、Session Management(会话管理)、Cryptography(加密),各种安全框架解决的都是这几类问题,看名字就大概知道是什么意思了。
2. Shiro 配置与登录验证
为了使用 Shiro,首先要添加 maven 依赖,不过在项目开始的时候我就把这玩意儿写进去了,所以如果当时是复制的我的 pom.xml
,就不用重复添加了。
配置的顺序如下:
- 创建 Realm 并重写获取认证与授权信息的方法
- 创建配置类,包括创建并配置 SecurityManager 等。也可以通过 web.xml 启用 Shiro 过滤器,再通过 shiro.ini 文件进行配置,不过我们并没有 web.xml。既然用了 Spring Boot,就尽情地使用 Java 类吧
Realm 可以放在新的 package 里。参考代码如下:
public class WJRealm extends AuthorizingRealm {
@Autowired
private UserService userService;
// 简单重写获取授权信息方法
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
SimpleAuthorizationInfo s = new SimpleAuthorizationInfo();
return s;
}
// 获取认证信息,即根据 token 中的用户名从数据库中获取密码、盐等并返回
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
String userName = token.getPrincipal().toString();
User user = userService.getByUserName(userName);
String passwordInDB = user.getPassword();
String salt = user.getSalt();
SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(userName, passwordInDB, ByteSource.Util.bytes(salt), getName());
return authenticationInfo;
}
}
这里我们再次关注 salt,不知道为什么,SimpleAuthenticationInfo
中的 salt 非得存储成 byte[],于是我们又费劲绕了一遍把 String 搞了回去。不过这里的 byte[] 并不是我们当初随机生成的那个,而是随机生成的 byte[] 按 base64 编码成 String 又按 UTF-8 编码成的 byte[]。
为了搞明白上面这个问题,让我们来分析一下登录验证的过程。首先简单编写一个 Shiro 的配置类:
package com.gm.wj.config;
import com.gm.wj.realm.WJRealm;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ShiroConfiguration {
@Bean
public static LifecycleBeanPostProcessor getLifecycleBeanProcessor() {
return new LifecycleBeanPostProcessor();
}
@Bean
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
return shiroFilterFactoryBean;
}
@Bean
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(getWJRealm());
return securityManager;
}
@Bean
public WJRealm getWJRealm() {
WJRealm wjRealm = new WJRealm();
wjRealm.setCredentialsMatcher(hashedCredentialsMatcher());
return wjRealm;
}
@Bean
public HashedCredentialsMatcher hashedCredentialsMatcher() {
HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
hashedCredentialsMatcher.setHashAlgorithmName("md5");
hashedCredentialsMatcher.setHashIterations(2);
return hashedCredentialsMatcher;
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
}
这个配置类里决定了一些关键的选择。继续刚才的问题,编写使用 shiro 验证登录的代码:
@PostMapping(value = "/api/login")
@ResponseBody
public Result login(@RequestBody User requestUser) {
String username = requestUser.getUsername();
Subject subject = SecurityUtils.getSubject();
// subject.getSession().setTimeout(10000);
UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(username, requestUser.getPassword());
try {
subject.login(usernamePasswordToken);
return ResultFactory.buildSuccessResult(username);
} catch (AuthenticationException e) {
String message = "账号密码错误";
return ResultFactory.buildFailResult(message);
}
}
可以看出,在实际开发中,我们只需要调用一句 subject.login(usernamePasswordToken)
就可以执行验证,然而你无法想象这背后发生了多少动人的故事。大概经过七八层调用,Shiro 通过 Realm 里我们重写的 doGetAuthenticationInfo
方法获取到了验证信息,再根据我们在配置类里定义的 CredentialsMatcher(HashedCredentialsMatcher),执行如下方法:
public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
Object tokenHashedCredentials = this.hashProvidedCredentials(token, info);
Object accountCredentials = this.getCredentials(info);
return this.equals(tokenHashedCredentials, accountCredentials);
}
在这个方法里,accountCredentials
获取到了我们存在数据库中的 hash 后的密码, 而 tokenHashedCredentials
则调用 hash 算法根据 salt 和客户端传入的 password 算出了 hash 值。(这个 password 是一个 char[],在调用 SimpleHash() 方法时被转为了 byte[])
通过分析算法的源码,发现其执行过程是先对 salt 执行 hash,再对 password 执行 hash 并把值追加上去。结果和把 salt 连上 password 再 hash 是一样的,但不是我们想的密码在前盐在后。
再分析 equals 方法,发现最终调用的是 java.security.MessageDigest
包中的 isEqual()
方法,讲道理这个方法才是逻辑的核心(比较两个摘要(hash 值)是否相等),但是却被埋没在层层封装之下,没什么特别的,顾名思义吧:
public static boolean isEqual(byte[] digesta, byte[] digestb) {
if (digesta == digestb) return true;
if (digesta == null || digestb == null) {
return false;
}
if (digesta.length != digestb.length) {
return false;
}
int result = 0;
// time-constant comparison
for (int i = 0; i < digesta.length; i++) {
result |= digesta[i] ^ digestb[i];
}
return result == 0;
}
总结一下,我们的问题,为什么 Shiro 方法里的 salt 要用 byte[] 而不是用 String?
答案是,Shiro 提供的 hash 算法本质上是由 Java 提供的 MessageDigest 类实现,其输入和输出都是 byte[],这样设计的目的,我猜主要是考虑到效率与通用性。但如果我是 Shiro 的开发者,我会简单做一个封装,虽然看似没有必要,但是会减少很多初学者的困惑。
这篇文章的内容不少了,先写到这一步吧。
上面的内容我不是像以前那样按功能实现步骤去讲解,而是分析了一下代码背后的逻辑,因为调用 Shiro 的 API 其实很简单,但是说实话这种能力并没有太大价值。成为搬砖工程师还是顶级架构师,很大程度上取决于分析问题解决问题的能力。虽然逐渐脱离开发岗位,但我的技术之路才刚刚起步,并且会一直走下去。希望能够通过在教程中和大家唠唠嗑这种方式总结自己曾经爬过的坑,权当为新入行的同学们抛砖引玉了。
3.测试
编写好上面的代码后,尝试登录系统,会发现新注册的用户可以登录,而原来的所有账户都失效了!没办法,这是一个不可逆的升级改造。有两种解决方案:
一,把原来的用户删除,再重新注册一下。
二,注册一个用户,随便写用户名,密码和老用户一样,完成后在数据库里把盐和加密后的密码拷贝过去。
对于已经投入运行的系统来说,做这种更改十分蛋疼,所以最好还是一开始就设计到位。不过毕竟技术一直在发展,真遇到了这种问题也要想办法解决,比如在备份库中执行脚本自动更新相应内容。
三、下一步
不知道你们有没有发现以下问题:
- 我们的系统现在每次重启浏览器都得重新登录
- 没有登出(logout)功能
- 拦截交由前端判断,判断的依据是 localStorage 中是否存有用户信息。这种信息我们完全可以伪造,比如在控制台输入
window.localStorage.setItem('user', JSON.stringify({"name":"哈哈哈"}));
,就可以绕过登录了
应该如何去完善呢?且听下回分解。