SpringSecurity学习笔记
一. 基本原理
- 本质是一个过滤器链
- 前后端分离的流程图如下
- 关键是UserDetailService中方法的重写,可以将用户数据从数据库中查出
- 在Service中手动调用ProviderManager进行校验
- jwt的生成和在redis中的储存,校验
- 权限管理
- 异常处理
二. 密码加密
- 往容器中注入BCryptPasswordEncoder,注入之后就会使用这种密码加密方式
- 在注册的时候就可以使用encode进行加密,存的是加密后的密码
- 有两个常用方法encode,matches
- encode可以把原文进行加密
- matches可以将原文和加密后的密码进行比较判断是否是相同的
- BCryptPasswordEncoder注入进容器中就会使用
- 这个时候,用户注册的时候存入数据库的密码要为加密后的密码,之后校验的时候就不用管了
// 在配置类中注入的加密
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
三. 登录流程
1. 思路
- 不走默认的UserPasswordAuth…filter,从controller将用户参数传入
- 在service中手动调用校验方法
- 实现UserDetailService自定义校验规则,用用户名去数据库查找,返回带有权限和密码的UserLogin对象
- 该对象会在manager中进行密码的校验,如果校验通过,生成一个jwt作为token返回前端
- 校验失败被异常处理器处理,也返回前端消息
- 同时将详细的用户信息存入redis中,方便后面每次请求来时候的比对
2. 登录Service
- controller就不写了,就是提供一个接口,之后调用service接口
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private RedisUtils redisUtils;
public R login(User user){
// 将用户名和密码丢给authenticationManager进行验证
UsernamePasswordAuthenticationToken upat = new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
Authentication authenticate = authenticationManager.authenticate(upat);
// 如果验证没通过,authenticate就会是null
if(Objects.isNull(authenticate)){
// 这里就会将错误丢到认证失败的异常丢给我们配置好的异常处理器,返回异常对应的结果给前端
throw new RuntimeException("登陆失败");
}
// 验证通过返回的authenticate可以获得一个UserDetails对象(这个就是自己写的实现类)
LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
User auth_user = loginUser.getUser();
String id = auth_user.getId().toString();
String jwt = JwtUtils.createJWT(id);
// 将用户信息存入redis
redisUtils.setCacheObject("login:"+id, loginUser);
Map<String, String>map = new HashMap<>();
map.put("msg", jwt);
return R.ok(map);
}
}
3. 实现UserDetailService
- 调用了ProviderManager的authenticate方法后,它会调用UserDetailService,而这里面的逻辑我们是想自己写的
- 自己写可以去数据库进行查找,而不是它初始项目中用它给我们的用户名和密码
- 自己写,实现UserDetailService接口即可
@Service
public class MyUserDetailService implements UserDetailsService {
@Autowired
UserMapper userMapper;
@Autowired
PasswordEncoder passwordEncoder;
@Autowired
MenuMapper menuMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
QueryWrapper<com.geology.geology_system_server.pojo.User> wrapper = new QueryWrapper<>();
wrapper.eq("username", username);
com.geology.geology_system_server.pojo.User user = userMapper.selectOne(wrapper);
if(user == null){
throw new UsernameNotFoundException("无该用户");
}
System.out.println("鉴权成功");
// 从数据库中查询用户的权限信息
List<String> permission = menuMapper.selectPermsByUserId(user.getId());
// 默认的User类也是UserDetails的实现类
// 返回查询到的用户的用户名和密码封装成User对象,这个User对象可以是自己写的,只要是实现了UserDetails就可以
LoginUser loginUser = new LoginUser(user, permission);
return loginUser;
}
}
4. UserDetail对象的封装
- 重写的这个方法的返回值是一个UserDetail对象,这个对象也自己定制一下,让该对象拥有用户名,密码,权限信息
@NoArgsConstructor
@Data
public class LoginUser implements UserDetails {
private User user;
private List<String> permissions; // 数据库传来的权限字符串信息
// 这个不需要存进redis中
@JSONField(serialize = false)
private List<GrantedAuthority> authorities = null;
public LoginUser(User user, List<String> permission){
this.user = user;
this.permissions = permission;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// 如果已经创建过权限集合,下次来就不需要再遍历数据库查来的权限字符串了
if (authorities!=null){
return authorities;
}
// 将权限信息封装到GrantedAuthority的实现类中
// authorities = new ArrayList<>();
// for (String permission : permissions) {
// SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(permission);
// authorities.add(simpleGrantedAuthority);
// }
authorities = permissions.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
5. 校验通过后生成token并存入redis
- 之后的逻辑就是,如果校验成功了,就可以拿到authenticate对象,从该对象可以获得之前返回的LoginUser,就可以根据userId生成jwt,并将用户信息存入redis
- 如果拿不到,就说明认证失败了,就抛出异常即可
四. Security的总配置类
- 可能要整个文章看了一遍才看得懂Security配置类中一个个都是什么意思,先写在这
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// 密码加密
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
@Override
protected AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
@Autowired
JwtFilter jwtFilter;
@Autowired
AuthenticationEntryPoint authenticationEntryPoint;
@Autowired
AccessDeniedHandler accessDeniedHandler;
// 有关放行的配置
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
// 不通过session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/user/login").anonymous() // anonymous表示未登录才能访问,如果登录则不能访问
// .antMatchers("/user").hasAuthority("admin") //也可以在这里进行权限配置,和注解那个是一样的
// 除此之外都要进行鉴权认证
.anyRequest().authenticated();
// 将jwt过滤器添加到usernamepas...之前
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
// 配置认证失败和授权失败的处理器
http.exceptionHandling()
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler);
// 允许跨域
http.cors();
}
}
五. 验证token流程
- 登录过后,再去访问需要登录的资源时,就要校验token
1. 思路:
- 查看请求头是否有token,没有的话直接放行给后面的过滤器处理
- 有token就用Jwt去解析,解析出来userId
- 拿这个userId去redis中查找,看是否能查出UserLogin对象
- 如果查到了对象,就将其放入SecurityContextHolder中,放行
2. JwtFilter过滤器编写
- 因此在每次请求打来的时候,需要经过jwt校验的Filter,需要手动配置这个过滤器
@Component
public class JwtFilter extends OncePerRequestFilter {
@Autowired
RedisUtils redisUtils;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader("token");
if (StringUtils.isEmpty(token)){
// 如果没有token,也放行,因为后面会有别的过滤器来过滤token
filterChain.doFilter(request, response);
return;
}
// 解析token
String userId;
try {
Claims claims = JwtUtils.parseJWT(token);
userId = claims.getSubject();
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("token异常");
}
// 从redis中获取token
String userKey = "login:" + userId;
LoginUser loginUser = redisUtils.getCacheObject(userKey);
if(Objects.isNull(loginUser)){
// redis中未查到
throw new RuntimeException("用户未登录");
}
// 如果redis中查到了,就存入SecurityContextHolder,还有对应的权限信息
UsernamePasswordAuthenticationToken upat = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(upat);
// 放行
filterChain.doFilter(request,response);
}
}
3. 配置类中特定位置添加过滤器
-
这里是否在redis中查到都放行了,但是如果查到了,会将LoginUser对象和对应的权限信息放进SecurityContextHolder中,如果没放的话,后续自有filter会处理认证失败的情况
-
之后要在Security配置类中将JwtFilter放在UsernamePasswordAuthenticationFilter之前,这样每次来就会先经过这个filter
六. 登出设置
思路:
- 由于登出的前提是之前已经登陆了,因此访问的时候也经过了jwtFilter,因此用户信息还存在SecurityContextHolder
- 将redis中的用户对象删除即可,因为下一次经过jwtFilter的时候从redis中取不到对象,就不会把这个用户放入SecurityContextHolder了
public R logout(){
// 从ContextHolder中获取授权对象
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
boolean suc = redisUtils.deleteObject("login:" + loginUser.getUser().getId());
if(suc){
R.ok("登出成功");
}
return R.error("登录失败");
}
七. 权限设置
- 权限设置在只在前端是不够的,前端防君子,后端防小人
- 在FilterSecurityInterceptor中会从SecurityContextHolder中获取权限信息,判断用户是否拥有当前资源的访问权限
1. 开启权限注解
- 在security的配置类上添加注解@EnableGlobalMethodSecurity(prePostEnabled = true)
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}
2. 指定资源需要什么权限
- 在controller中可以指定某些路径下的资源需要特定权限才可以访问
@PreAuthorize("hasAnyAuthority('data:write', 'data:read')") // 有这些权限中的任意一个就可以访问
// @PreAuthorize("hasAuthority('role')") // 有这个权限才可以访问
// @PreAuthorize("hasRole('admin')") // 会 将ROLE_ 这个拼接到用户权限之前,最终看有没ROLE_admin这个权限
// @PreAuthorize("@myExpression.hasAuthority('data:write')") // 通过获得到注入了的bean中的方法(自定义的校验方法)来判断
@GetMapping("/hello")
........
3. 将UserLogin对象进行权限改装
- 在UserLogin中需要加入权限相关配置
- 关键是在重写的getAuthorities中需要将权限字符串依次变为GrantedAuthority,封装进一个集合中返回
@NoArgsConstructor
@Data
public class LoginUser implements UserDetails {
private User user;
private List<String> permissions; // 数据库传来的权限字符串信息
// 这个不需要存进redis中
@JSONField(serialize = false)
private List<GrantedAuthority> authorities = null;
public LoginUser(User user, List<String> permission){
this.user = user;
this.permissions = permission;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// 如果已经创建过权限集合,下次来就不需要再遍历数据库查来的权限字符串了
if (authorities!=null){
return authorities;
}
// 将权限信息封装到GrantedAuthority的实现类中
// authorities = new ArrayList<>();
// for (String permission : permissions) {
// SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(permission);
// authorities.add(simpleGrantedAuthority);
// }
authorities = permissions.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
4. 将权限信息放进SecurityContextHolder
- 在JwtFilter中如果查到token了说明该用户登陆过,同时把权限信息放入到上下文中
八. 对授权失败和认证失败的处理器
- 如果登录认证失败或授权失败,都抛出了异常,可以通过实现两个类分别处理授权失败和认证失败
1. 认证失败
- 实现AccessDeniedHandler
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
// 授权失败的处理器
R res = R.error(HttpStatus.FORBIDDEN.value(), "您的权限不够");
WebUtils.renderString(response, JSON.toJSONString(res));
}
}
2. 授权失败
- 实现AuthenticationEntryPoint
@Component
//异常处理
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
// 认证失败的处理器
R res = R.error(HttpStatus.UNAUTHORIZED.value(), "认证失败");
WebUtils.renderString(response, JSON.toJSONString(res));
}
}