# Shiro

# SpringSecurity

Spring Security 是 Spring 家族中的一个安全管理框架。一般来说中大型的项目都是使用SpringSecurity 来做安全框架。小项目用Shiro的比较多,因为相比与SpringSecurity,Shiro的上手更加的简单。

一般的Web应用需要进行认证和授权,是SpringSecurity作为安全框架的核心功能

  • 认证(Authentication):验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户
  • 授权(Authorization):经过认证后判断当前用户是否有权限进行某个操作

# 快速入门

搭建一个简单的SpringBoot工程,引入依赖后访问之前的接口就会自动跳转到一个SpringSecurity的默认登陆页面,默认用户名是user,密码会输出在控制台

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-security</artifactId>
</dependency>

# 登录校验流程

登录校验流程

# 基本原理

SpringSecurity的原理其实是一个过滤器链,内部包含了各种功能的过滤器,如下是几个重要的过滤器

基本原理

//认证过滤器,处理登录页面的登录请求。默认的账号密码认证主要是他负责
UsernamePasswordAuthenticationFilter 

//异常处理过滤器,处理过滤器链抛出的AccessDeniedException和AuthenticationException
ExceptionTranslationFilter 

//授权过滤器,负责权限校验的过滤器
FilterSecurityInterceptor

查看完整过滤器链的流程

完整的过滤器链

# 认证流程

入门案例的认证流程图

详细介绍 (opens new window)

Authentication接口
# 表示当前访问系统的用户,封装了前端传入的用户相关信息

AuthenticationManager接口
# 定义了认证的方法

UserDetailsService接口
# 定义了一个根据用户名查询用户信息的方法(默认在内存中),返回UserDetails对象

UserDetails接口
# 提供核心用户信息。将UserDetails对象信息封装到Authentication对象中

# 自定义登录校验流程

登录
# 第一步:自定义UserDetailsService接口,在这个实现类中去查询数据库
# 第二步:自定义登录接口,调用ProviderManager的authenticate方法进行认证

校验
# 第三步:自定义Jwt认证过滤器,根据token中的userid,将redis中的用户信息存入SecurityContextHolder

自定义登录校验流程

# 授权流程

在SpringSecurity中,会使用默认的FilterSecurityInterceptor来进行权限校验。在FilterSecurityInterceptor中会从SecurityContextHolder获取其中的Authentication,然后获取其中的权限信息。

# 第一步:把当前登录用户的权限信息也存入Authentication
# 第二步:设置我们的资源所需要的权限

# 核心代码实现

  • 创建一个类实现UserDetailsService接口,重写其中的方法。增加用户名从数据库中查询用户信息
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{
        //根据用户名查询用户信息
        LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(User::getUserName,username);
        User user = userMapper.selectOne(wrapper);
        //如果查询不到数据就通过抛出异常来给出提示
        if(Objects.isNull(user)){
            throw new RuntimeException("用户名或密码错误");
        }
        // TODO根据用户查询权限信息 添加到LoginUser中
        List<String> list = new ArrayList<>(Arrays.asList("test"));
        Set<SimpleGrantedAuthority> authoritiesSet = new HashSet<SimpleGrantedAuthority>();
        for (String roles : list) {
            SimpleGrantedAuthority role = new SimpleGrantedAuthority(roles);
            authoritiesSet.add(role);
        }
        
        //封装成UserDetails对象返回 
        return new LoginUser(user,authoritiesSet);
    }
}
  • UserDetailsService的返回值是UserDetails类型,需要定义一个实现该接口的类,把用户信息封装在其中
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginUser implements UserDetails, Serializable {
    private User user;
	//权限信息
    private Set<? extends GrantedAuthority> authorities;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.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;
    }
}

注意

此时如果你想让用户的密码是明文存储,需要在密码前加{noop}

  • 替换PasswordEncoder的加密方式,一般使用SpringSecurity为我们提供的BCryptPasswordEncoder
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}
  • 自定义登陆接口,通过AuthenticationManager的authenticate方法来进行用户认证,认证成功返回jwt
@RestController
public class LoginController {

    @Autowired
    private LoginServcie loginServcie;

    @PostMapping("/user/login")
    public ResponseResult login(@RequestBody User user){
        return loginServcie.login(user);
    }
}



@Service
public class LoginServiceImpl implements LoginServcie {

    @Autowired
    private AuthenticationManager authenticationManager;
    @Autowired
    private RedisCache redisCache;

    @Override
    public ResponseResult login(User user) {
        UsernamePasswordAuthenticationToken authenticationToken = 
		new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
		//通过AuthenticationManager的authenticate方法来进行用户认证
        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);
        //authenticate存入redis
        redisCache.setCacheObject("login:"+userId,loginUser.getUser());
        //把token响应给前端
        HashMap<String,String> map = new HashMap<>();
        map.put("token",jwt);
        return new ResponseResult(200,"登陆成功",map);
    }
}
  • 定义一个认证过滤器,根据请求头中token包含的userid去redis中获取对应的LoginUser对象。然后封装Authentication对象存入SecurityContextHolder
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    @Autowired
    private RedisCache redisCache;

    @Override
    protected void doFilterInternal(HttpServletRequest request, 
	                                HttpServletResponse response, 
									FilterChain filterChain) 
									throws ServletException, IOException {
        //获取token
        String token = request.getHeader("token");
        if (!StringUtils.hasText(token)) {
            //放行
            filterChain.doFilter(request, response);
            return;
        }
        //解析token
        String userid;
        try {
            Claims claims = JwtUtil.parseJWT(token);
            userid = claims.getSubject();
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("token非法");
        }
        //从redis中获取用户信息
        String redisKey = "login:" + userid;
        Object cacheObject = redisCache.getCacheObject(redisKey);
        User User = (User)cacheObject;
        if (Objects.isNull(User)) {
            throw new RuntimeException("用户未登录");
        }
        // TODO获取权限信息封装到Authentication中
        List<String> list = new ArrayList<>(Arrays.asList("test"));
        Set<SimpleGrantedAuthority> authoritiesSet = new HashSet<SimpleGrantedAuthority>();
        for (String roles : list) {
            SimpleGrantedAuthority role = new SimpleGrantedAuthority(roles);
            authoritiesSet.add(role);
        }
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(User, null, authoritiesSet);
        //存入SecurityContextHolder
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        //放行
        filterChain.doFilter(request, response);
    }
}
  • 退出登陆:根据SecurityContextHolder中的认证信息,删除redis中对应的数据即可
public ResponseResult logout() {
	Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
	User User = (User) authentication.getPrincipal();
	Long userid = User.getId();
	redisCache.deleteObject("login:"+userid);
	return new ResponseResult(200,"退出成功");
}
  • 自定义失败处理,在认证或者授权的过程中出现了异常会被ExceptionTranslationFilter捕获到
//认证过程中出现的异常会被封装成AuthenticationException
//然后调用AuthenticationEntryPoint对象的方法去进行异常处理
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);
    }
}

//授权过程中出现的异常会被封装成AccessDeniedException
//然后调用AccessDeniedHandler对象的方法去进行异常处理
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);
    }
}
  • 基于注解的权限控制方案,可以使用注解去指定访问对应的资源所需的权限
//再配置文件中开启相关配置
@EnableGlobalMethodSecurity(prePostEnabled = true)

@RestController
public class HelloController {

    @PreAuthorize("hasAuthority('test')")
	//传入多个权限,只要用户有其中任意一个权限都可以访问对应资源
	@PreAuthorize("hasAnyAuthority('admin','test','system:dept:list')")
	//要求有对应的角色才可以访问,但是会把我们传入的参数拼接上 ROLE_ 再去比较
	@PreAuthorize("hasRole('system:dept:list')")
	@PreAuthorize("hasAnyRole('admin','system:dept:list')")
	//在SPEL表达式中使用 @ex相当于获取容器中bean的名字
	@PreAuthorize("@ex.hasAuthority('system:dept:list')")
	
	@RequestMapping("/hello")
    public String hello(){
        return "hello";
    }
}
  • SecurityConfig 对象完整内容
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig{
    @Bean
    public UserDetailsService userDetailsService() {
        return new UserDetailsServiceImpl();
    }

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public JwtAuthenticationTokenFilter authenticationJwtTokenFilter() {
        return new JwtAuthenticationTokenFilter();
    }

    @Bean
    public AuthenticationEntryPoint authenticationEntryPoint(){
        return new AuthenticationEntryPointImpl();
    }

    @Bean
    public AccessDeniedHandler accessDeniedHandler(){
        return new AccessDeniedHandlerImpl();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
			// 禁用basic明文验证
			.httpBasic().disable()
			//前后端分离架构不需要csrf保护
			.csrf().disable()
			// 禁用默认登录页
			.formLogin().disable()
			// 禁用默认登出页
			.logout().disable()
			// 处理认证失败、鉴权失败
			.exceptionHandling(ex -> ex.authenticationEntryPoint(authenticationEntryPoint())
									   .accessDeniedHandler(accessDeniedHandler()))
			//默认的情况下将认证信息放到HttpSession中,前后端分离的情况不需要
			.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
			.and()
			//开始设置权限
			.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
					// 对于登录接口 允许匿名访问
					// 配置一个request Mather的string数组,参数为url路径格式
					.antMatchers("/user/login").permitAll()
					// 配置一个request Mather数组,参数为RequestMatcher 对象
					.requestMatchers(a->a.getRequestURI().startsWith("/register")).denyAll()
					// 允许错误地址匿名访问
					.requestMatchers(a->a.getRequestURI().startsWith("/error")).permitAll()
					// 其他所有接口必须有Authority信息,且是admin权限
					.antMatchers("/**").hasAnyAuthority("admin")
					// 除上面外的所有请求全部需要鉴权认证
					.anyRequest().authenticated())
			.authenticationProvider(authenticationProvider())
			.addFilterBefore(authenticationJwtTokenFilter()
			                ,UsernamePasswordAuthenticationFilter.class)
			//允许跨域,需要先对SpringBoot配置跨域
            .cors();

        return http.build();
    }

    /**
     * 调用loadUserByUsername获得UserDetail信息
     * @return
     */
    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        // 从自定义的 userDetailsService.loadUserByUsername 方法获取UserDetails
        authProvider.setUserDetailsService(userDetailsService());
        // 设置密码编辑器
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

    /**
     * 登录时需要调用AuthenticationManager.authenticate执行一次校验
     * @param config
     * @return
     * @throws Exception
     */
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
	                                                                    throws Exception {
																			
        return config.getAuthenticationManager();
    }

    /**
     * 忽略某些URL请求
     * @return
     */
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        //这些请求将被忽略,这意味着这些URL将有受到 CSRF、XSS、Clickjacking 等攻击的可能
        return (web) -> web.ignoring().antMatchers("/images/**", "/js/**");
    }
}

# 授权

# Sa-Token

Sa-Token (opens new window) 是一个轻量级 java 权限认证框架