1.初始准备
首先搭建一个spring boot项目 使用idea搭建maven项目导入以下依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.gyd</groupId>
<artifactId>security</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.0</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>
创建主启动类以及controller
运行看是否在网页端返回了数据,如果返回数据则成功
项目运行成功!
接下来加入security的相关包,依赖如下
再次运行该项目
可以看到holle请求被转化成login请求了,这是spring security帮我们自行做验证,现在默认是所有的请求都需要登陆才能访问
2.认证
2.1认证流程
2.2原理初探
以下是完整的过滤器链
usernamePassword这个filter就是负责输入用户名和密码过后的登录请求的处理,同时负责上述的登录页面的认证
ExceptionTranslationFilter:处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException
FilterSecuritylnterceptor:负责权限校验的过滤器。
2.2认证流程详解
(注意:3、4、5所调用的方法均是调用的后面接口或者类的方法,因此我们重写UserDetailsService的实现类的时候重写的实际上是loadUserByUsername方法)
------------------------分割线,回头再写--------------------------------------------------------------------------
3.UserDetailsService的重写
首先实现userdetails接口
然后重写loadUserByUsername
下面是数据库保存的账号和密码,由于security默认有加密方式,所以需要注明{noop}来表示明文比对
进行登录
可以看到访问成功!
(注意:访问网页的时候一定要在controller内部进行映射否则就会出现以下问题,上面就是输入hello是被程序自动拦截让你登陆,直接访问8080端口被拦截即使是登录成功也会出现下面的错误,本人修正这个问题修正了一个小时,实在是太笨了!!!
也许是因为因为控制层并没有写该网页路径实现?----个人猜测)
4.登录实例
做一个标准的登录流程(新建以下文件)
主要是接口实现层的逻辑实现
前面的原理流程图有说过通过AuthenticationManager的authenticate实现认证,但是该方法需要一个Authentication类型的参数传递进去
点开这个方法
查看Authentication的实现类,很明显我们需要的是用户的用户名跟密码登录的流程,因此使用UsernamePasswordAuthenticationToken这个实现类来接收用户名跟密码
调用了这么多方法过后,程序已经运行到我们重写的userDetails那里了,还记得吗?这个时候已经开始执行下面的箭头所包含的步骤了
我们返回了一个loginuser类
可以在此处打断点调试,看看里面是否已经有了loginuser这个类,打断点以前需要在配置文件里面做出如下配置
可以看到,Principal其实是LoginUser的类型了,于是就可以通过该类获取id并且存入redis中
接下来是完整逻辑代码
@Override
public ResponseResult login(User user) {
//使用AuthenticationManager authentication进行认证
UsernamePasswordAuthenticationToken UPT=new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
Authentication authentication = authenticationManager.authenticate(UPT);
//如果登陆失败,抛出异常
if (Objects.isNull(authentication))
{
throw new RuntimeException("登陆失败");//抛出异常后方法后面的代码将不会执行
}
//如果登陆成功,则通过获取userId来生成jwt 再把完整的数据存放到redis中
LoginUser principal = (LoginUser) authentication.getPrincipal();
String userid = principal.getUser().getId().toString();
//通过工具类转化出token
String token = JwtUtil.createJWT(userid);
//存放到redis
redisCache.setCacheObject("login:"+userid,principal);
//定义返回封装类
Map<String,String> tokenMap=new HashMap<>();
tokenMap.put("token",token);
return new ResponseResult(200,"登陆成功",tokenMap);
}
访问这个接口(注意:需要提前打开本地的redis服务,不然redis工具类往redis存放数据的时候会报错导致程序运行失败)
登陆成功
可以打开redis看看存入的数据
5.校验过程实现
在前后端分离的架构中,前端在用户登录成功后会获取一个 JWT,并在每次请求携带该 JWT。后端服务通过这段代码对 JWT 进行解析和验证,获取用户信息,并进行相应的权限校验,保护敏感接口不被未授权用户访问。
因此我们需要自己写一个过滤器来实现对token的校验,以下是代码以及流程的实现
@Component
public class JwtTokenFilter extends OncePerRequestFilter {
@Autowired
private RedisCache redisCache;
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
//先从请求头中获取token
String token = httpServletRequest.getHeader("token");
//如果token为空则放行,因为这是自己定义的对token解析的过滤器,
//我们需要放在UsernamePasswordAuthenticationFilter这个过滤器前面,这里放行了后面会自行处理(比如抛出异常)
if (!StringUtils.hasText(token))
{
filterChain.doFilter(httpServletRequest,httpServletResponse);
return;
}
String userId;
//从token中解析id
try {
Claims claims = JwtUtil.parseJWT(token);
userId=claims.getSubject();
} catch (Exception e) {
throw new RuntimeException("token异常");
}
//从redis中获取用户信息
LoginUser loginUser = redisCache.getCacheObject("login:" + userId);
if (Objects.isNull(loginUser))//如果获取出来的用户数据是空
{
throw new RuntimeException("登录用户数据异常");
}
//存入SecurityContextHolder
/**
* 携带三个参数的构造函数的原因
* 经过上面的步骤说明这个用户是合法用户,所以权限验证是合法的,三参构造方法会在这里将权限设置为true代表合法
*/
UsernamePasswordAuthenticationToken authenticationToken=new UsernamePasswordAuthenticationToken(loginUser,null,null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
//放行
filterChain.doFilter(httpServletRequest,httpServletResponse);
}
}
(关于为什么要用三个参数的构造函数的源码,设置为true代表已认证)
还记得过滤器链那张图嘛?懒得翻没关系 贴下面
我们自己写一个过滤器将他放在鼠标指示的过滤器之前,我们能只是重新写了一个过滤器,需要对过滤器的位置进行配置,所以还需要在config中进行配置
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtTokenFilter jwtTokenFilter;
@Bean
public PasswordEncoder passwordEncoder()
{
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()//关闭csrf
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
//对于登录接口 允许匿名访问
.antMatchers("/user/login").anonymous()
.antMatchers("/user/logout").authenticated()
// 除上面外的所有请求全部不需要认证即可访问
.anyRequest().permitAll();
//关闭springSecurity自带的注销登录功能
http.logout().disable();
//将自定义的token过滤器设置在内置过滤器UsernamePasswordAuthenticationFilter的前面
http.addFilterBefore(jwtTokenFilter,UsernamePasswordAuthenticationFilter.class);
//允许跨域
http.cors();
}
}
测试:
注意在接口放行这里,要将/hello请求设置为需要认证。
接下来对该过滤器实施测试
首先登录,拿到token
我们先尝试不携带token访问自己写的一个简易接口,显而易见,访问失败了,这也进一步说明我们过滤器那边放行的操作会被过滤器链后面的其他过滤器给处理。
携带token进行访问,可以看到访问成功
6.退出登录
用户注销就是需要想办法获取用户的id然后从redis中删除这个用户信息,这样在经过我们之前自定义的过滤器的时候,由于我们自定义的过滤器很靠前,所以在redis中拿不到信息的时候就会直接抛出异常,因此注销登陆只需要删除redis中的数据即可
下面是基于SpringSecurity的注销代码:
@Override
public ResponseResult logout() {
//先从SecurityContextHolder中获取用户的id
UsernamePasswordAuthenticationToken authentication = (UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
Long userId=loginUser.getUser().getId();
//根据id去删除redis中的信息
redisCache.deleteObject("login:" + userId);
return new ResponseResult(200,"注销成功");
}
检验代码是否成功的方法,首先登录过后拿到token访问之前写的hello接口
然后访问注销接口
再次访问hello接口
拒绝访问,说明注销成功
既然是根据用户id去redis中删除信息来实现注销功能,那么我们还可以这样,前端请求头中会含有token信息,我们直接把这个token获取到后端来用工具类去解析
@Override
public ResponseResult logout(String token) {
// //先从SecurityContextHolder中获取用户的id
// UsernamePasswordAuthenticationToken authentication = (UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
// LoginUser loginUser = (LoginUser) authentication.getPrincipal();
// Long userId=loginUser.getUser().getId();
// //根据id去删除redis中的信息
// redisCache.deleteObject("login:" + userId);
String userId;
try {
Claims claims = JwtUtil.parseJWT(token);
userId=claims.getSubject();
} catch (Exception e) {
throw new RuntimeException(e);
}
redisCache.deleteObject("login:"+userId);
return new ResponseResult(200,"注销成功");
}
经过测试一样可行,对token的检验在过滤器的时候就检验过了,所以无需再次检验token完整性、时效性、以及合法性,只是我也不知道在实际开发中这样的代码是否安全规范
7.用户授权问题
为什么要设计权限系统 ?
例如要设计一个图书管理系统,普通学生账号的权限不能使用书籍编辑、删除的功能,普通学生能使用的功能仅仅是浏览页面,但是,如果是图书管理员用户,那么就能使用所有权限。简单理解就是我们需要不同的用户使用不同的功能,这就是权限系统要实现的效果
虽然前端也可以去判断用户的权限来选择是否显示某些功能的页面或组件,但是不安全,因为如果有人知道了对应功能的接口地址就可以不通过前端,直接去发送请求来实现相关功能操作,所以我们还需要在后端进行用户权限的判断,判断当前用户是否有相应的权限,必须具有所需权限才能进行相应的操作
那么,如何开启权限验证?
首先在Securityconfog类添加如下注解
在对应的路径上放上需要的权限
最后就是去获取权限了,由于用户的权限都应该存放到数据库中
被遮挡的部分是sys_user_role
perms列是需要我们查询出来的,一个用户对应的perms就是他能访问的权限
获取权限列表需要重写LoginUser类的一个方法
通过简单的连表查询查询以及封装数据
测试
上面的用户访问成功,由于数据库没有id为1的用户所具有的权限我们再使用最开始的id为1的用户来测试一下,如果访问拒绝说明权限校验成功
可以看到拒绝访问,说明实现成功