1. 整体概述
权限管理包括用户身份认证和授权两部分,简称认证授权。对于需要访问控制到资源,用户首先经过身份认证,认证通过后用户具有该资源的访问权限方可访问。
1.1 认证概述
认证是确认用户身份的过程,确保用户是谁。
1.1.1 认证组件
Spring Security的认证过程涉及以下组件
AuthenticationManager:作为认证的核心,AuthenticationManager负责处理用户的认证请求。它会委托不同的认证提供者(例如DaoAuthenticationProvider)来验证用户身份。AuthenticationManager接收一个包含用户凭证(如用户名、密码)的Authentication对象,然后通过认证提供者验证这些凭证的正确性。如果验证通过,AuthenticationManager会返回一个包含认证信息的Authentication对象。
Authentication:Authentication表示用户的认证信息,通常包括用户名、密码以及认证后得到的角色和权限。它是Spring Security中用户身份验证的核心数据结构。Authentication接口通常由UsernamePasswordAuthenticationToken等实现类来表示。认证完成后,Authentication对象将包含用户的认证状态和权限信息。
SecurityContextHolder:SecurityContextHolder是Spring Security中的核心类之一,它用来存储当前用户的认证信息。认证成功后,Authentication对象会被保存在SecurityContext中,SecurityContext会由SecurityContextHolder管理。在每个请求周期内,SecurityContextHolder提供对当前用户认证信息的访问,使得后续的请求可以通过SecurityContextHolder.getContext().getAuthentication()来获取当前用户的身份信息。
1.1.2 认证过滤器链
1.1.3 认证流程步骤
1.2 授权概述
授权是根据用户身份信息判断用户是否有权限访问某些资源的过程。Spring Security的授权过程涉及以下组件:
AccessDecisionManager:AccessDecisionManager负责根据用户的认证信息和请求的资源,做出是否允许访问的决策。它会根据配置的权限要求以及用户的角色信息,决定用户是否能够访问特定的资源。AccessDecisionManager会使用多个AccessDecisionVoter来进行投票,综合多个投票结果后,做出最终的访问决策。
AccessDecisionVoter:AccessDecisionVoter是负责对用户访问权限进行投票的组件。它根据用户的Authentication对象和访问资源的ConfigAttribute(例如角色或权限要求)进行匹配。每个AccessDecisionVoter会判断用户是否符合特定的访问要求,并给出投票结果。所有投票的结果会由AccessDecisionManager汇总,最终决定是否允许访问。
ConfigAttribute:ConfigAttribute用于描述受保护资源的权限要求。它定义了访问某个资源所需的权限(如角色或操作权限)。ConfigAttribute通常与AccessDecisionManager结合使用,它告知AccessDecisionVoter该资源需要哪些权限,AccessDecisionVoter则根据这些要求判断用户是否具备访问该资源的权限。
1.3. 引入依赖
一旦引入Spring security依赖后,系统中所有的资源都受保护起来,必须进行认证之后才能够访问,没有认证直接访问资源时,会跳转到Spring security默认的登录页面:http://localhost:8080/login
<!--Spring security -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId>
</dependency>
Spring security会默认提供一个用户和密码,用户是user,密码是启动时的一个字符串:
如果觉得启动时生成的默认密码比较长不方便登录,也可以使用配置文件配置固定的用户名和密码,密码前缀{noop}表示密码是明文。
2. 实现思路
整个基于security框架实现认证授权思路如上所示,绿色框是框架已经实现的内容,白色框需要用户改写和实现的内容。
2.1 认证登陆
完成上图中白色框中的内容,具体步骤如下所示。
2.1.1 查询数据库用户
步骤1: 通过登录用户名查数据库中用户信息
通过自定义UserDetailsService,改写里面的loadUserByUsername方法,利用mybatis或其他框架从数据库中查询出用户信息。
除此之外,改写UserDetails接口中的方法,把数据库中查出的用户信息封装成UserDetails进行返回。
@Service
public class UserDetailsServiceImpl implements UserDetailsService {@Autowiredprivate UserMapper userMapper;@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {//查询用户信息LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(User::getUserAccount,username);User user = userMapper.selectOne(queryWrapper);if(user==null){throw new RuntimeException("此用户"+username+" 不存在");}//TODO 查询对应的权限信息// 把数据封装成UserDetailsreturn new LoginUser(user);}
}
@Data // get set 方法
@NoArgsConstructor // 空参构造器
@AllArgsConstructor // 全参构造器
public class LoginUser implements UserDetails {private User user;@Overridepublic Collection<? extends GrantedAuthority> getAuthorities() {return null;}@Overridepublic String getPassword() {return user.getPassWord();}@Overridepublic String getUsername() {return user.getUserAccount();}@Overridepublic boolean isAccountNonExpired() {return true;}@Overridepublic boolean isAccountNonLocked() {return true;}@Overridepublic boolean isCredentialsNonExpired() {return true;}@Overridepublic boolean isEnabled() {return true;}
}
完成以上代码后,在数据库中存入用户,打开security默认登录页面测试是否能够使用数据库中存入的用户进行页面登录。
注意:因为目前还未做用户密码加密,密码是按明文存储的,所以在密码前需要加上{noop}标识
弹出以上界面说明登录成功,第一步操作完成。
2.1.2 密码加密功能
步骤2: 用户密码加密功能
增加security配置,指定加密方式,从而自动实现用户密码按加密方式匹配,并要求用户新增接口和修改接口中,密码字段要调用配置中的加密方式进行明文加密,然后再存储数据库中。
增加security配置,定义密码加密方式。
@Configuration
@EnableWebSecurity
public class SecurityConfig {// 配置密码加密方式,全局自动按这个方式加密@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}
}
增加此配置后,所有密码均采用此方式进行加密和匹配,当用户输入明文密码登录时,security框架会自动进行此方法进行加密后和数据库密码进行匹配。
注意:此时数据库存储的密码不应该是明文了,在用户注册或修改密码时,也应该调用此加密方法对明文进行加密并存储到数据库中。
此接口提供了两个方法,一个是明文加密,一个是密文匹配,测试方法如下:
2.1.3 登录接口编写
步骤3: 登录接口实现
首先在security配置类中配置认证管理器AuthenticationManager,此组件的作用是通过传入前端输入的用户名和密码,调用UserDetailsServicel去后台数据库比对用户信息,如果认证成功返回数据库用户的详情信息,失败返回null。
传入用户名和密码前需要封装成authenticationToken对象,根据认证结果编写代码处理逻辑。如果认证失败,抛出异常报错给前端,如果认证成功,返回UserDetailsServicel的LoginUser用户,通过LoginUser获取userID,生成jwt返回给前端
// 配置认证管理器@Beanpublic AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {return authenticationConfiguration.getAuthenticationManager();}
编写登录接口controller以及service实现类
@RestController
@CrossOrigin
@RequestMapping("api/v1")
@Tag(name= "用户登录接口文档") // 描述controller类的作用
@Slf4j
public class LoginController {@AutowiredLoginService loginService;@PostMapping("/authentication/login")@Operation(summary = "用户登陆接口")public R login(@RequestBody UserLoginVo user){try {Map<String, String> userLogin = loginService.login(user);return R.ok("登录成功",userLogin);}catch (Exception e){log.error(e.getMessage());return R.error(500,e.getMessage());}}
}
@Override
public Map<String, String> login(UserLoginVo user) {// AuthenticationManger authenticate 进行用户认证UsernamePasswordAuthenticationToken authenticationToken =new UsernamePasswordAuthenticationToken(user.getUserAccount(),user.getPassWord()); // 用户名和密码封装成 authenticationToken对象Authentication authenticate = authenticationManager.authenticate(authenticationToken); // 通过authenticationManager进行验证// 如果认证没通过,给出对应提示if(Objects.isNull(authenticate)){throw new RuntimeException("登录失败");}// 如果认证通过了,使用userid生成一个jwt, jwt存入ResponseResult返回LoginUser loginUser = (LoginUser) authenticate.getPrincipal();String userId = loginUser.getUser().getUserId().toString();String jwt = JwtUtil.createJWT(userId);// 把用户信息存入redis
// redisService.setValue("login:" + userId, JSON.toJSONString(loginUser));redisService.setObject("login:" + userId,loginUser);// 把jwt封装成map返回前端HashMap<String, String> map = new HashMap<>();map.put("token",jwt);return map;
}
值得注意的是,由于登录接口是匿名登录的,需要进行放行,否则接口需要认证,无法访问。具体配置如下:
在配置中建议登录接口以及注册接口使用.anonymous(),仅对未认证(匿名)用户开放,已认证用户不可访问此资源。
在一些公开资源、静态资源、开放接口等建议使用.permitAll(),不需要任何身份验证,即允许已认证和未认证的用户。
// 3.设置路径权限
http.authorizeHttpRequests().requestMatchers("/api/v1/authentication/login").anonymous() //对于登录接口,允许匿名访问.requestMatchers("/doc.html", "/swagger-ui/**", "/v3/api-docs/**").permitAll() // 允许匿名访问这些路径.anyRequest().authenticated(); // 除上面外的所有请求全部需要鉴权认证
通过如上步骤,最终接口登录成功后会返回token
2.2 校验
2.2.1 定义jwt认证过滤器
步骤1:获取token, 解析token获取其中的userid
步骤2:通过userid 从 redis中获取用户信息
步骤3: 将用户信息存入securityContextHolder
如果在登录时存入 SecurityContextHolder,但应用是无状态的,每次请求时SecurityContextHolder 其实都是空的,无法保持状态。因为 Spring Security 的 SecurityContextHolder 只是一个线程级变量(ThreadLocal),它的生命周期仅限于当前请求的处理过程中。当一次 HTTP 请求到达服务器时:服务器分配一个线程处理请求。SecurityContextHolder 仅在该线程内存储 SecurityContext(认证信息)。当请求处理结束后,线程被回收,SecurityContextHolder 也被清空。
正确的做法是在用户每次请求时,解析 Token,并动态地将用户信息存入 SecurityContextHolder。
@Component
@Slf4j
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {@Autowiredprivate RedisService redisService;@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {// 获取tokenString token = request.getHeader("token");if (!StringUtils.hasText(token)) {// 没有token就放行,让后面过滤器处理filterChain.doFilter(request, response);return;}// 解析tokenString userID;try {Claims claims = JwtUtil.parseJWT(token);userID = claims.getSubject();} catch (Exception e) {throw new RuntimeException("token非法:" + e.getMessage());}// 获取用户信息
// Object userObj = redisService.getValue("login:" + userID);
// LoginUser user = JSON.parseObject((String) userObj, LoginUser.class);LoginUser user = redisService.getObject(("login:" + userID), LoginUser.class);if(user==null){throw new RuntimeException("用户未登录");}// 存入SecurityContextHolder//TODO 获取权限信息封装到Authentication中UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =new UsernamePasswordAuthenticationToken(user,null,null); // 用户、密码、权限集合SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);// 放行filterChain.doFilter(request,response);}
}
2.2.2 添加到过滤器链中
先注入过滤器实例,然后在security配置中添加倒数第二行代码
// 4. 添加认证过滤器
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
2.2.3 登出接口编写
因为认证过滤器对用户进行校验时,会根据token获取userid,并读取登录时存入redis中的用户信息,如果redis的用户信息读不到,就会被认证过滤器拦截,根据这一特性,可以设计登出接口,即删除用户在redis中的用户信息。
用户访问登出接口后,在SecurityContextHolder中获取userid,并删除redis中的信息
@Override
public void logout() {// 获取SecurityContextHolder中的用户IDUsernamePasswordAuthenticationToken authentication =(UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();LoginUser loginUser = (LoginUser) authentication.getPrincipal();Integer userId = loginUser.getUser().getUserId();// 删除redis中的值redisService.deleteValue("login:"+userId);
}
2.3 授权
2.3.1 设置资源权限
通过注解的方式进行资源权限的标识,决定各个接口需要什么样的权限才能访问。
步骤1:开启注解,在security配置类添加注解 @EnableMethodSecurity // 开启权限注解
@EnableMethodSecurity // 开启权限注解
public class SecurityConfig {...
}
步骤2: 在接口上添加注解配置权限 @PreAuthorize("hasAuthority('test')")
@PreAuthorize("hasAuthority('sys:device:getList')")
@GetMapping("/device/getList")
public R getDevice(@PathVariable("id") Integer id){...
}
步骤2中除了可以在接口上添加注解的方式设置权限,还可以基于配置进行权限设置,在securityConfig配置类中添加如下代码等同于注解:
http
.authorizeHttpRequests()
.requestMatchers("/api/v1/device/getList")
.hasAuthority("sys:device:getList");
2.3.2 封装权限信息
将用户的权限信息封装成security需要的对象,以便在登录和校验时,能够将权限信息输入到对应的结构中。
在LoginUser类中构造有参构造器,并重写getAuthorities方法;
public class LoginUser implements UserDetails {private User user;private List<String> permissions;/*** 有参构造器* */public LoginUser(User user,List<String> permissions){this.user = user;this.permissions = permissions;}/*** 获取权限对象* */@Overridepublic Collection<? extends GrantedAuthority> getAuthorities() {log.debug("permissions:"+permissions);ArrayList<GrantedAuthority> authorities = new ArrayList<>();// 把permissions中string类型的权限信息封装成simpleGrantedAuthority对象for (String permission : permissions) {SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permission);authorities.add(authority);log.debug("authorities:"+authorities);}return authorities;}
}
在UserDetailsService中调用LoginUser的有参构造器,将用户信息和权限信息注入到LoginUser实例中,以供登录时AuthenticationManager认证管理器调用返回用户权限信息。登录接口获取到认证管理器的用户权限信息后会存入redis中,在下一步中,认证过滤器会取出相关权限信息存入到SecurityContextHolder中以供后续过滤器校验。
@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {//查询用户信息LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();queryWrapper.eq(User::getUserAccount,username);User user = userMapper.selectOne(queryWrapper);if(user==null){throw new RuntimeException("此用户"+username+" 不存在");}//查询对应的权限信息List<PermissionSelectVo> permissions = userService.getUserByAccount(username).getPermissions();ArrayList<String> permissionList = new ArrayList<>();for (PermissionSelectVo permission:permissions){permissionList.add(permission.getPermissionCode());}// 把数据封装成UserDetailsreturn new LoginUser(user,permissionList);}
2.3.3 注入权限信息
在用户登录时,需要把权限信息注入到LoginUser中并存入redis,因为在上一步中已经将权限信息放入LoginUser中,所以在登录后,直接将LoginUser存入redis中就自然存入了权限信息。
在校验用户时,过滤器中从redis读出权限信息,传入UsernamePasswordAuthenticationToken中,生成带权限的用户信息存入SecurityContextHolder让其后续的过滤器进行权限校验。
// 存入SecurityContextHolder// 获取权限信息封装到Authentication中
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(loginUser,null,loginUser.getAuthorities()); // 用户、密码、权限集合 SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
3 其他功能
3.1 异常捕获
如果在认证过程中出现的异常会被封装成AuthenticationException然后调用AuthenticationEntryPoint对象的方法进行一场处理。
如果是授权过程中出现的异常会被封装成AccessDeniedException然后调用AccessDeniedHandler对象的方法去进行异常处理。所以如果我们需要自定义异常处理,给前端返回异常信息,只需要自定义AuthenticationEntryPoin 和 AccessDeniedHandler然后配置给Spring Security 即可。
3.1. 1 认证异常
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {@Overridepublic void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {// 处理异常response.setStatus(HttpStatus.UNAUTHORIZED.value()); // 设置状态码response.setContentType("application/json");response.setCharacterEncoding("utf-8");response.getWriter().write(JSON.toJSONString(R.error(401,"尚未认证,请进行认证操作!")));}
}
3.1.2 权限异常
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {@Overridepublic void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {response.setStatus(HttpStatus.FORBIDDEN.value()); // 设置状态码response.setContentType("application/json");response.setCharacterEncoding("utf-8");response.getWriter().write(JSON.toJSONString(R.error(403,"无权访问!")));}
}
3.2 跨域
在Spring Security框架中,如果已经配置了Spring的跨域处理,通常还需要针对Spring Security进行跨域配置,两者都需要配置才能确保跨域请求能够顺利通过Spring Security的安全检查。一般情况下,为了保证配置的统一性,会把配置集中使用其中一个配置,另一个配置直接放行
3.2.1 Spring跨域配置
@Configuration
public class WebConfig implements WebMvcConfigurer {@Overridepublic void addCorsMappings(CorsRegistry registry) {registry.addMapping("/**") //设置允许跨域的路径.allowedOrigins() //设置允许跨域请求的域名,例如:allowedOrigins("http://localhost:3000", "http://example.com"),如果为空则是允许所有.allowCredentials(true) //是否允许发送凭证token.allowedMethods("GET","POST","PUT","DELETE") //指定允许的 HTTP 方法.maxAge(3600 * 24); //预检请求有效期}
}
3.2.2 Security跨域配置
// 跨域配置
http.cors().configurationSource(corsConfigurationSource()) //跨域解决方案
@Bean
CorsConfigurationSource corsConfigurationSource(){CorsConfiguration corsConfiguration = new CorsConfiguration();corsConfiguration.setAllowedHeaders(Arrays.asList("*"));corsConfiguration.setAllowedMethods(Arrays.asList("*"));corsConfiguration.setAllowedOrigins(Arrays.asList("*"));corsConfiguration.setMaxAge(3600L);UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();source.registerCorsConfiguration("/**",corsConfiguration);return source;}
3.3 csrf攻击
前后端分离架构本身就是无状态token校验的,所以天然防范csrf攻击,无需进行设置,默认打开的,所以在前后端架构下需要设置为关闭。
http.csrf().disable(); // 关闭csrf
4 总结
本文通过引入security框架,在登录接口中通过传入页面用户登录信息(用户名、密码)给UsernamePasswordAuthenticationToken来验证用户身份,通过后返回token给前端并存入redis用户信息。其中验证的用户身份是通过实现接口UserDetailsService从数据库中获取用户信息并封装到LoginUser对象中。校验时,通过添加认证过滤器完成从token获取用户信息,并调用UsernamePasswordAuthenticationToken验证用户权限。
security的完整配置文件如下所示:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // 开启权限注解
public class SecurityConfig {@Autowiredprivate JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;// 注入token校验过滤器@Autowiredprivate AuthenticationEntryPoint authenticationEntryPoint; //注入授权异常处理器@Autowiredprivate AccessDeniedHandler accessDeniedHandler; //注入认证异常处理器// 配置密码加密方式,全局自动按这个方式加密@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}// 配置认证管理器@Beanpublic AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {return authenticationConfiguration.getAuthenticationManager();}// security 配置@Beanpublic SecurityFilterChain httpSecurity(HttpSecurity http) throws Exception {// 1.前后端分离架构本身就是无状态token校验的,所以天然防范csrf攻击,可以关闭http.csrf().disable(); // 关闭csrf
// .csrfTokenRepository(CookieCsrfTokenRepository. withHttpOnlyFalse()); // 将令牌保存到cookie中允许cookie前端获取// 2.前后端分离架构不通过Session获取SecurityContexthttp.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);// 3.设置路径权限http.authorizeHttpRequests().requestMatchers("/api/v1/authentication/login").anonymous() //对于登录接口,允许匿名访问.requestMatchers("/doc.html", "/swagger-ui/**", "/v3/api-docs/**").permitAll() // 允许匿名访问这些路径.anyRequest().authenticated(); // 除上面外的所有请求全部需要鉴权认证// 4. 添加认证过滤器http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);// 5. 配置异常处理器http.exceptionHandling().authenticationEntryPoint(authenticationEntryPoint) // 配置认证失败处理器.accessDeniedHandler(accessDeniedHandler); // 配置授权失败处理器// 6.跨域配置http.cors(); // 允许跨域return http.build();}
}