SpringSecurity

登录校验流程

image-20230721145753191

SpringSecurity完整流程

image-20230721145848979

UsernamePasswordAuthenticationFilter:负责处理我们在登陆页面填写了用户名密码后的登陆请求。认证工作主要有它负责。

**ExceptionTranslationFilter:**处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException 。

**FilterSecurityInterceptor:**负责权限校验的过滤器。

UsernamePasswordAuthenticationFilter认证流程

image-20230721145929585

Authentication接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息。

AuthenticationManager接口:定义了认证Authentication的方法

UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法。

UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中。

登录认证具体实现流程

创建一个类实现UserDetails接口

该类主要封装了用户信息,且作为UserDetailService的返回类型,后续用于密码比对等

@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginUser implements UserDetails {

    private User user;

    private List<String> permission;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

    @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;
    }
}

创建一个类实现UserDetailService接口

该类的主要作用是根据用户名去查询对应的用户是否存在

若存在则将用户的密码和权限等信息封装到UserDetails中,用于后续的密码校验以及鉴权

注意,UserDetailService虽然进行了数据库查询操作,但密码的校验并不在此进行,而是需要后续通过后续由PasswordEncoder进行校验,此校验步骤会在调用authenticate方法时自动进行

@Service
public class UserDetailServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Autowired
    private MenuMapper menuMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 根据用户名查询用户信息
        LambdaQueryWrapper<User> userLambdaQueryWrapper = new LambdaQueryWrapper<>();
        userLambdaQueryWrapper.eq(User::getUserName,username);
        top.alobox.domain.entity.User user = userMapper.selectOne(userLambdaQueryWrapper);
        // 判断是否查到用户 如果没查到抛出异常
        if (Objects.isNull(user)) {
            throw new RuntimeException("用户不存在");
        }


        // 查询权限信息封装
        // 如果是后台用户才需要权限封装
        if(user.getType().equals(SystemConstants.ADMIN)) {
            List<String> perms = menuMapper.selectPermsByUserId(user.getId());
            return new LoginUser(user,perms);
        }

        return new LoginUser(user,null);
    }
}

创建登录处理服务层类

该类的主要作用是调用认证流程,若认证通过需要根据用户id生成token并返回给用户的浏览器,同时将认证所得的LoginUser对象存入redis中,后续进行token校验及鉴权时需要从redis中获取相关信息进行比对。

注销操作也在该类进行,注销的主要流程即将redis中的loginuser删除即可

@Service
public class LoginServiceImpl implements LoginService {
    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private RedisCache redisCache;

    @Override
    public ResponseResult login(User user) {
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
        Authentication authenticate = authenticationManager.authenticate(authenticationToken);
        //判断是否认证通过
        if(Objects.isNull(authenticate)){
            throw new RuntimeException("用户名或密码错误");
        }
        //获取userid 生成token
        LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
        String userId = loginUser.getUser().getId().toString();
        String jwt = JwtUtil.createJWT(userId);
        //把用户信息存入redis
        redisCache.setCacheObject("adminlogin:"+userId,loginUser);

        //把token封装返回
        HashMap<String, String> map = new HashMap<>();
        map.put("token",jwt);
        return ResponseResult.okResult(map);
    }

    @Override
    public ResponseResult logout() {
        // 获取userid
        Long userId = SecurityUtils.getUserId();
        // 删除redis中的用户信息
        redisCache.deleteObject("adminlogin:" + userId);
        return ResponseResult.okResult();
    }
}

Security配置类

该类主要对security相关设置进行配置,在登陆流程中,较为关键的有以下几点

  1. 配置密码加密方式,用户密码在存储和校验时都以加密的形式进行,默认使用的PasswordEncoder要求数据库中的密码格式为:{id}password 。它会根据id去判断密码的加密方式。但是我们一般不会采用这种方式。所以就需要替换PasswordEncoder

    @Bean
        public PasswordEncoder passwordEncoder(){
            return new BCryptPasswordEncoder();
        }
    
  2. 注入AuthenticationManager,在登录处理服务层类中进行用户校验时,需要手动调用AuthenticationManager的authenticate方法,因此需要在security配置类中创建AuthenticationManager对象并注入ioc中

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
    
  3. 关闭默认的注销操作

    security具有默认注销操作,一般情况下都需要根据项目需求重写,故直接关闭默认注销

    http.logout().disable();
    
  4. 允许跨域

    前后端分离项目中,前后端间的端口通常不一致,因此需要开启允许跨域请求

    http.cors();
    
  5. 关闭session

    前后端分离项目中,通过jwt进行互信鉴权,要求每个请求都携带必要信息,因此不再需要使用session进行用户信息存储

    .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    

请求token鉴权具体流程

创建JWT校验过滤器

对每条请求进行过滤,并获取其中所携带的token信息,并对token进行校验

需要注意的是,如果请求中没有携带token字段,则认为该请求不需要进行认证,一般为登录请求

校验流程如下:

  1. 获取请求中携带的token

    String token = httpServletRequest.getHeader("token");
    
  2. 解析token获取userid

    Claims claims = JwtUtil.parseJWT(token);
    String userId = claims.getSubject();
    
  3. 获取redis中对应的LoginUser,如果无法获取,则token已过期,前端告知需要重新登录

    // 从redis中获取用户信息
            LoginUser loginUser = redisCache.getCacheObject("adminlogin:" + userId);
            // 如果获取不到
            if(Objects.isNull(loginUser)){
                // 说明登录过期 提示重新登录
                ResponseResult responseResult = ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
                WebUtils.renderString(httpServletResponse, JSON.toJSONString(responseResult));
                return;
            }
    
  4. 将LoginUser对象放入SecurityContextHolder

    每个线程都有独立的SecurityContextHolder,存储了当前请求用户的信息,后续可以从SecurityContextHolder获取LoginUser对象并获取用户信息

    // 存入SecurityContextHolder
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, null);
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
    
            filterChain.doFilter(httpServletRequest,httpServletResponse);
    
  5. 将过滤器添加到SpringSecurity过滤器中

    	@Autowired
        private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
    
    	@Override
        protected void configure(HttpSecurity http) throws Exception {
            // 把jwtAuthenticationTokenFilter添加到SpringSecurity的过滤器中
            http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        }
    

完整代码

@Component
public class JwtAuthenticationTokenFilter 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");
        if (!StringUtils.hasText(token)) {
            // 说明该接口不需要登录,直接放行
            filterChain.doFilter(httpServletRequest,httpServletResponse);
            return;
        }
        // 解析获取userid
        Claims claims = null;
        try {
            claims = JwtUtil.parseJWT(token);
        } catch (Exception e) {
            e.printStackTrace();
            // token超时 token非法
            // 响应告诉前端需要重新登录
            ResponseResult responseResult = ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
            WebUtils.renderString(httpServletResponse, JSON.toJSONString(responseResult));
            return;
        }
        String userId = claims.getSubject();
        // 从redis中获取用户信息
        LoginUser loginUser = redisCache.getCacheObject("adminlogin:" + userId);
        // 如果获取不到
        if(Objects.isNull(loginUser)){
            // 说明登录过期 提示重新登录
            ResponseResult responseResult = ResponseResult.errorResult(AppHttpCodeEnum.NEED_LOGIN);
            WebUtils.renderString(httpServletResponse, JSON.toJSONString(responseResult));
            return;
        }
        // 存入SecurityContextHolder
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, null);
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);

        filterChain.doFilter(httpServletRequest,httpServletResponse);
    }
}

鉴权具体实现流程

开启基于注解的权限控制功能

在配置类中使用@EnableGlobalMethodSecurity(prePostEnabled = true)注解,开启请求前权限校验

在需用进行权限校验的控制层方法上使用@PreAuthorize注解

注解中需要调用一下方法之一:hasAuthority、hasRole、hasAnyRole

以hasAuthority方法为例,其可以传入多个权限,用户有其中任意一个权限都可以访问对应资源。

   @PreAuthorize("hasAnyAuthority('admin','test','system:dept:list')")
    public String hello(){
        return "hello";
    }

hasAnyAuthority方式内部其实是调用authentication(实际为LoginUser)的getAuthorities方法获取用户的权限列表。然后判断我们存入的方法参数数据是否在权限列表中。

自定义权限校验方法

一般情况下都不会使用以上的三个方法,而是自定义权限校验方法进行权限校验

将该权限校验类命名为ps,使用时在@PreAuthorize中传入"@ps.hasPermission('{权限名}')"即可调用自定义权限校验方法进行权限校验。

@Service("ps")
public class PermissionService {

    /**
     * 判断当前用户是否具有permission
     * @param permission
     * @return
     */
    public boolean hasPermission(String permission) {
        // 如果是超级管理员,直接返回true
        if (SecurityUtils.isAdmin())
            return true;

        // 否则获取当前用户权限列表
        List<String> perms = SecurityUtils.getLoginUser().getPermission();
        return perms.contains(permission);
    }
}
@PreAuthorize("@ps.hasPermission('system:menu:edit')")
    @PutMapping
    public ResponseResult updateMenu(@RequestBody MenuDto menuDto) {
        Menu menu = BeanCopyUtils.copyBean(menuDto, Menu.class);
        return menuService.updateMenu(menu);
    }

自定义异常处理

一般需要在在认证失败或者是授权失败的情况下也能和我们的接口一样返回相同结构的json,这样可以让前端能对响应进行统一的处理。

在SpringSecurity中,如果我们在认证或者授权的过程中出现了异常会被ExceptionTranslationFilter捕获到。在ExceptionTranslationFilter中会去判断是认证失败还是授权失败出现的异常。

  1. 如果是认证过程中出现的异常会被封装成AuthenticationException然后调用AuthenticationEntryPoint对象的方法去进行异常处理。
  2. 如果是授权过程中出现的异常会被封装成AccessDeniedException然后调用AccessDeniedHandler对象的方法去进行异常处理。

所以如果我们需要自定义异常处理,我们只需要自定义AuthenticationEntryPoint和AccessDeniedHandler然后配置给SpringSecurity即可。

@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        ResponseResult result = new ResponseResult(HttpStatus.FORBIDDEN.value(), "权限不足");
        String json = JSON.toJSONString(result);
        WebUtils.renderString(response,json);

    }
}
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        ResponseResult result = new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "认证失败请重新登录");
        String json = JSON.toJSONString(result);
        WebUtils.renderString(response,json);
    }
}

配置到SpringSecurity

	@Autowired
    private AuthenticationEntryPoint authenticationEntryPoint;

    @Autowired
    private AccessDeniedHandler accessDeniedHandler;

http.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint).
                accessDeniedHandler(accessDeniedHandler);