我们平时会使用Md5来做授权认证,不管是session还是jwt,但是如果被别人知道了salt还是很危险的,所以这次就来介绍一个强哈希方法来进行加密,其代表的框架也就是SpringSecurity提供的BCryptPasswordEncoder类。
另外这里将会主要介绍jwt的加密认证授权方式,因为无状态的登录相对于有状态的登录对于服务器的压力更小,并且redis本身不用去存储这个session就更好了。
BCrypt强哈希方法,每次加密的结果都是不同的。
在开始介绍之前,先简单介绍一下基于Token去进行身份验证的方法流程:
- 客户端使用用户名跟密码请求登录
- 服务端收到请求,去验证用户名和密码
- 验证成功之后,服务端签发一个token,再将token发送给客户端
- 客户端收到token存储起来,比如放在cookie
- 客户端每次向服务端请求资源的时候需要待着服务端签发的Token
- 服务端收到请求,然后去验证客户端请求里面带着的Token,如果验证成功,就向客户端返回请求到的数据
然后再说一下Token机制相对于有状态的几个优点:
- 支持CORS
- 无状态
- 更适合用CDN:可以通过内容分发网络请求你服务端的所有资料,而服务端只要提供API
- 去耦
- 更适用于移动应用
- CSRF
- 性能:一次网络往返时间(通过数据库查询session)比HS256加密时间更长
- 不需要为登录页面做特殊处理。
紧接着是jwt的三大组成部分:
- Header
指明我们使用的是jwt的基本信息,比如加密算法的种类和编码的形式
- Playload
存放有效信息,这个信息分为三部分:
(1) 标准中注册的声明
(2) 公共的声明
(3) 私有的声明
- Signature
签证信息也由三部分组成:header(base64后的),payload(base64后的),secret
好了,无聊的话说完了,还是直接上代码:
项目实战:
分为两部分:
(1)首先是最基本的加密认证登录(非JWT知识,可略)
首先导入xml文件
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
然后将SpringSecurity的安全配置文件导入
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
//当我们需要具体配置的时候,就把空方法拿出来进行配置
/**
* authorizeRequests所有security全注解配置实现的开端,表示开始说明需要的权限。
* 需要的权限分两部分,第一部分是拦截的路径,第二部分访问该路径需要的权限
* antMatches表示拦截什么路径,permitAll表示什么url可以通行
* anyRequests任何的请求,authenticated认证后才能访问
* .and().csrf().disable()固定写法,表示csrf拦截失效
*
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/**").permitAll()
.anyRequest().authenticated()
.and().csrf().disable();
}
}
上面配置好了最基本的拦截器,然后我来说一下BCrypt加密,也就是这篇文章的重点。和Md5不同,以前我们用Md5的时候,都是直接getPwd然后md5一下,和数据库的去比对,但是这个加密,每次加密的结果都是不一样的,不过幸好他有一个api可以帮助我们完成加密之后的认证。
(2)然后是基于Java的JJWT实现JWT
首先导入依赖:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.6.0</version>
</dependency>
然后我们来写一个关于jwt的demo,加密与解密。
首先是创建CreateJwt进行加密
public class CreateJwt {
public static void main(String[] args) {
//生成jwt
JwtBuilder jwtBuilder = Jwts.builder()
.setId("登录用户id")
.setSubject("Username")
.setIssuedAt(new Date())
.signWith(SignatureAlgorithm.HS256, "salt")
.setExpiration(new Date(new Date().getTime()+60000));//设置过期时间,单位ms
//将编码转化成string
System.out.println(jwtBuilder.compact());
}
}
然后是parseJwt进行解密
public class ParseJwt {
public static void main(String[] args) {
Claims salt = Jwts.parser().setSigningKey("salt")
.parseClaimsJws("eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiLnmbvlvZXnlKjmiLdpZCIsInN1YiI6IlVzZXJuYW1lIiwiaWF0IjoxNTcyNDE4ODQ3LCJleHAiOjE1NzI0MTg5MDd9.Xaayf3FqOmsfmTYItnCkZxv6_eCZH-ab3ORqE5yElds")
.getBody();
System.out.println("用户id:"+salt.getId());
System.out.println("用户登录时间:"+new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(salt.getIssuedAt()));
System.out.println("用户过期时间:"+new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(salt.getExpiration()));
System.out.println("用户名:"+salt.getSubject());
}
}
因为我们设置了过期时间,所以我们先运行然后等待一分钟之后用加密后的秘钥进行重新运行。
首先是正常的运行
等待一分钟后:
证明我们的加密解密是生效的。
※额外的Demo拓展
由于我们平时进行认证的时候,都是将用户的role和permission一起传给后端,所以往往需要自定义一个传值,也就需要claim方法了。
Demo介绍完毕之后,我们可以利用demo进行一个封装,让我们直接使用Jwt加密和解密的api。
- JWTUtils
@ConfigurationProperties("jwt.config")
public class JwtUtil {
private String key ;
private long ttl ;//一个小时
public String getKey() {
return key;
}
public void setKey(String key) {
this.key = key;
}
public long getTtl() {
return ttl;
}
public void setTtl(long ttl) {
this.ttl = ttl;
}
/**
* 生成JWT
*
* @param id
* @param subject
* @return
*/
public String createJWT(String id, String subject, String roles) {
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
JwtBuilder builder = Jwts.builder().setId(id)
.setSubject(subject)
.setIssuedAt(now)
.signWith(SignatureAlgorithm.HS256, key).claim("roles", roles);
if (ttl > 0) {
builder.setExpiration( new Date( nowMillis + ttl));
}
return builder.compact();
}
/**
* 解析JWT
* @param jwtStr
* @return
*/
public Claims parseJWT(String jwtStr){
return Jwts.parser()
.setSigningKey(key)
.parseClaimsJws(jwtStr)
.getBody();
}
}
然后现在让我们将Jwt融入之前的认证与授权中。
- 基于JWT的接口授权
我先说一下我们的思路,接下来将会展示一个Admin(管理员)账户认证与授权的案例,因为admin与user级别同理,只是user有很多角色,这个可以自己去指定,而admin这里是定死只有admin角色,所以用admin展示会比较方便。
然后admin角色,肯定无法是手动注册的,肯定是需要老板去给admin角色添加的,但是我们还是写了注册这个接口。
接口也是完成了对于password的加密操作,加密的算法引用的是SpringSecurity的BCryptPasswordEncoder加密方法,这个在上面也介绍过了。
紧接着,进行了admin账户的认证接口的开发,让客户端输入的明文去与mysql的密文通过SpringSecurity的api去对比,如果合格了,就会将role和token传过来,当然,token里也可以通过上面提到的claim进行自定义传值。
然后下面开始进行实施。
首先是admin用户的添加操作:
public void add(Admin admin) {
admin.setId( idWorker.nextId()+"" );
//密码加密
admin.setPassword(encoder.encode(admin.getPassword()));//加密方法
adminDao.save(admin);
}
然后,我们利用上面的service逻辑手动制造一个admin用户
再利用这个数据去模拟登陆,登陆接口如下:
public Admin login(Admin admin) {
//先根据用户名查询对象。
Admin adminLogin = adminDao.findByLoginname(admin.getLoginname());
//然后拿数据库中的密码和用户输入的密码匹配是否相同。
System.out.println(admin.getPassword());
System.out.println(adminLogin.getPassword());
if(adminLogin!=null && encoder.matches(admin.getPassword(), adminLogin.getPassword())){
//保证数据库中的对象中的密码和用户输入的密码是一致的。登录成功
return adminLogin;
}
//登录失败
return null;
}
结果如下
证明我们登录成功了,那么我们这段token里,也就自带了我们的各种信息(包括role),我们可以添加到HTTP请求头中,进行接口是否可以访问的认证。
假设我们写一个接口,这个接口不管实现业务是什么,但是必须得是admin角色才可以去执行:
Service逻辑:首先我们得获取到请求头,并且针对Http header进行过滤,拿出我们的token,对token去执行我们BCryptPasswordEncoder里面的解密操作,拿出我们的role去进行判断。
代码如下:
/**
* 删除 必须有admin角色才能删除
* @param id
*/
public void deleteById(String id) {
// String token = (String) request.getAttribute("claims_admin");
// if (token==null || "".equals(token)){
// throw new RuntimeException("权限不足!");
// }
// userDao.deleteById(id);
String header = request.getHeader("Authorization");
if (StringUtils.isEmpty(header)) {
throw new RuntimeException("请先登录");
}
if (!header.startsWith("Bearer ")) {
throw new RuntimeException("权限不足");
}
//得到token
String token = header.substring(7);
try {
Claims claims = jwtUtil.parseJWT(token);
String role = claims.get("role").toString();
if (role == null || role.equals("admin")) {
throw new RuntimeException("权限不足");
}
} catch (Exception e) {
throw new RuntimeException("权限不足");
}
userDao.deleteById(id);
}
- 基于JWT的接口授权逻辑优化
之前我们的可以看到deleteById接口的实现方法是,从http请求中,找到Authorization的key 过滤掉Bearer 最后得到token,通过token再parseJwt,得到claim。
但是我们是否可以将这一系列过程封装呢。
事实上SpringMVC就可以做到这一点,也就是Interceptor。但是我们在SpringBoot中该如何让他进行激活呢,那就是将拦截器进行注册。
事实上SpringBoot是有这种注册机制的,也是通过WebMvcConfigurationSupport去实现的。
首先我们对这种SpringBoot的注册机制进行配置,并且做一个基本的拦截。
@Configuration
public class InterceptorConfig extends WebMvcConfigurationSupport {
@Autowired
private JwtInterceptor jwtInterceptor;
@Override
protected void addInterceptors(InterceptorRegistry registry) {
//注册拦截器要声明拦截的url和拦截的http请求
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/**") //拦截所有的url
.excludePathPatterns("/**/login/**"); //除了login
}
}
然后将我们引入的jwtInterceptor进行一个注入,完成一个基于jwt的拦截政策
@Component
public class JwtInterceptor implements HandlerInterceptor {
@Autowired
private JwtUtil jwtUtil;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
System.out.println("经过了拦截器");
//无论如何放行,具体能不能操作还是在具体的操作中去判断。
//拦截器只负责把请求头中包含token的令牌进行一个解析验证
String header = request.getHeader("Authorization");
if (header!= null && !"".equals(header)) {
if (!header.startsWith("Bearer ")) {
throw new RuntimeException("权限不足");
}
//得到token
String token = header.substring(7);
//对令牌进行检验
try {
Claims claims = jwtUtil.parseJWT(token);
String role = claims.get("role").toString();
if (role != null || role.equals("admin")) {
request.setAttribute("claims_admin", token);
}
if (role != null || role.equals("user")) {
request.setAttribute("claims_user", token);
}
} catch (Exception e) {
throw new RuntimeException("令牌不正确");
}
}
return true;
}
}