ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Security+JWT+Redis实现项目级前后端分离认证授权

Spring Security+JWT+Redis实现项目级前后端分离认证授权 1. 整体概述权限管理包括用户身份认证和授权两部分简称认证授权。对于需要访问控制到资源用户首先经过身份认证认证通过后用户具有该资源的访问权限方可访问。1.1 认证概述认证是确认用户身份的过程确保用户是谁。1.1.1 认证组件Spring Security的认证过程涉及以下组件AuthenticationManager作为认证的核心AuthenticationManager负责处理用户的认证请求。它会委托不同的认证提供者例如DaoAuthenticationProvider来验证用户身份。AuthenticationManager接收一个包含用户凭证如用户名、密码的Authentication对象然后通过认证提供者验证这些凭证的正确性。如果验证通过AuthenticationManager会返回一个包含认证信息的Authentication对象。AuthenticationAuthentication表示用户的认证信息通常包括用户名、密码以及认证后得到的角色和权限。它是Spring Security中用户身份验证的核心数据结构。Authentication接口通常由UsernamePasswordAuthenticationToken等实现类来表示。认证完成后Authentication对象将包含用户的认证状态和权限信息。SecurityContextHolderSecurityContextHolder是Spring Security中的核心类之一它用来存储当前用户的认证信息。认证成功后Authentication对象会被保存在SecurityContext中SecurityContext会由SecurityContextHolder管理。在每个请求周期内SecurityContextHolder提供对当前用户认证信息的访问使得后续的请求可以通过SecurityContextHolder.getContext().getAuthentication()来获取当前用户的身份信息。1.1.2 认证过滤器链1.1.3 认证流程步骤1.2 授权概述授权是根据用户身份信息判断用户是否有权限访问某些资源的过程。Spring Security的授权过程涉及以下组件AccessDecisionManagerAccessDecisionManager负责根据用户的认证信息和请求的资源做出是否允许访问的决策。它会根据配置的权限要求以及用户的角色信息决定用户是否能够访问特定的资源。AccessDecisionManager会使用多个AccessDecisionVoter来进行投票综合多个投票结果后做出最终的访问决策。AccessDecisionVoterAccessDecisionVoter是负责对用户访问权限进行投票的组件。它根据用户的Authentication对象和访问资源的ConfigAttribute例如角色或权限要求进行匹配。每个AccessDecisionVoter会判断用户是否符合特定的访问要求并给出投票结果。所有投票的结果会由AccessDecisionManager汇总最终决定是否允许访问。ConfigAttributeConfigAttribute用于描述受保护资源的权限要求。它定义了访问某个资源所需的权限如角色或操作权限。ConfigAttribute通常与AccessDecisionManager结合使用它告知AccessDecisionVoter该资源需要哪些权限AccessDecisionVoter则根据这些要求判断用户是否具备访问该资源的权限。1.3. 引入依赖一旦引入Spring security依赖后系统中所有的资源都受保护起来必须进行认证之后才能够访问没有认证直接访问资源时会跳转到Spring security默认的登录页面http://localhost:8080/login!--Spring security -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependencySpring security会默认提供一个用户和密码用户是user密码是启动时的一个字符串如果觉得启动时生成的默认密码比较长不方便登录也可以使用配置文件配置固定的用户名和密码密码前缀{noop}表示密码是明文。2. 实现思路整个基于security框架实现认证授权思路如上所示绿色框是框架已经实现的内容白色框需要用户改写和实现的内容。2.1 认证登陆完成上图中白色框中的内容具体步骤如下所示。2.1.1 查询数据库用户步骤1: 通过登录用户名查数据库中用户信息通过自定义UserDetailsService改写里面的loadUserByUsername方法利用mybatis或其他框架从数据库中查询出用户信息。除此之外改写UserDetails接口中的方法把数据库中查出的用户信息封装成UserDetails进行返回。Service public class UserDetailsServiceImpl implements UserDetailsService { Autowired private UserMapper userMapper; Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { //查询用户信息 LambdaQueryWrapperUser queryWrapper new LambdaQueryWrapper(); queryWrapper.eq(User::getUserAccount,username); User user userMapper.selectOne(queryWrapper); if(usernull){ throw new RuntimeException(此用户username 不存在); } //TODO 查询对应的权限信息 // 把数据封装成UserDetails return new LoginUser(user); } }Data // get set 方法 NoArgsConstructor // 空参构造器 AllArgsConstructor // 全参构造器 public class LoginUser implements UserDetails { private User user; Override public Collection? extends GrantedAuthority getAuthorities() { return null; } Override public String getPassword() { return user.getPassWord(); } Override public String getUsername() { return user.getUserAccount(); } Override public boolean isAccountNonExpired() { return true; } Override public boolean isAccountNonLocked() { return true; } Override public boolean isCredentialsNonExpired() { return true; } Override public boolean isEnabled() { return true; } }完成以上代码后在数据库中存入用户打开security默认登录页面测试是否能够使用数据库中存入的用户进行页面登录。注意因为目前还未做用户密码加密密码是按明文存储的所以在密码前需要加上{noop}标识弹出以上界面说明登录成功第一步操作完成。2.1.2 密码加密功能步骤2: 用户密码加密功能增加security配置指定加密方式从而自动实现用户密码按加密方式匹配并要求用户新增接口和修改接口中密码字段要调用配置中的加密方式进行明文加密然后再存储数据库中。增加security配置定义密码加密方式。Configuration EnableWebSecurity public class SecurityConfig { // 配置密码加密方式全局自动按这个方式加密 Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }增加此配置后所有密码均采用此方式进行加密和匹配当用户输入明文密码登录时security框架会自动进行此方法进行加密后和数据库密码进行匹配。注意此时数据库存储的密码不应该是明文了在用户注册或修改密码时也应该调用此加密方法对明文进行加密并存储到数据库中。此接口提供了两个方法一个是明文加密一个是密文匹配测试方法如下2.1.3 登录接口编写步骤3: 登录接口实现首先在security配置类中配置认证管理器AuthenticationManager此组件的作用是通过传入前端输入的用户名和密码调用UserDetailsServicel去后台数据库比对用户信息如果认证成功返回数据库用户的详情信息失败返回null。传入用户名和密码前需要封装成authenticationToken对象根据认证结果编写代码处理逻辑。如果认证失败抛出异常报错给前端如果认证成功返回UserDetailsServicel的LoginUser用户通过LoginUser获取userID生成jwt返回给前端// 配置认证管理器 Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); }编写登录接口controller以及service实现类RestController CrossOrigin RequestMapping(api/v1) Tag(name 用户登录接口文档) // 描述controller类的作用 Slf4j public class LoginController { Autowired LoginService loginService; PostMapping(/authentication/login) Operation(summary 用户登陆接口) public R login(RequestBody UserLoginVo user){ try { MapString, String userLogin loginService.login(user); return R.ok(登录成功,userLogin); }catch (Exception e){ log.error(e.getMessage()); return R.error(500,e.getMessage()); } } }Override public MapString, 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(); // 每次登录生成唯一 jti会话按 login:userId:jti 存储登出仅失效当前端、旧 token 不复活 String jti JwtUtil.getUUID(); String jwt jwtUtil.createJWT(jti, userId, AuthConstants.LOGIN_TOKEN_TTL_DAYS * AuthConstants.MILLIS_PER_DAY); // 把用户信息存入redis redisService.setObject(login: userId : jti, loginUser); // 把jwt封装成map返回前端 HashMapString, 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(); // 除上面外的所有请求全部需要鉴权认证通过如上步骤最终接口登录成功后会返回token2.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 { Autowired private RedisService redisService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 获取token String token request.getHeader(token); if (!StringUtils.hasText(token)) { // 没有token就放行让后面过滤器处理 filterChain.doFilter(request, response); return; } // 解析token String 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(usernull){ 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中的用户ID UsernamePasswordAuthenticationToken authentication (UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication(); LoginUser loginUser (LoginUser) authentication.getPrincipal(); Integer userId loginUser.getUser().getUserId(); // 删除当前会话login:userId:jti仅失效当前端旧 token 不复活 Object details authentication.getDetails(); if (details instanceof String jti !jti.isEmpty()) { redisService.deleteValue(login: userId : jti); } }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 ListString permissions; /** * 有参构造器 * */ public LoginUser(User user,ListString permissions){ this.user user; this.permissions permissions; } /** * 获取权限对象 * */ Override public Collection? extends GrantedAuthority getAuthorities() { log.debug(permissionspermissions); ArrayListGrantedAuthority authorities new ArrayList(); // 把permissions中string类型的权限信息封装成simpleGrantedAuthority对象 for (String permission : permissions) { SimpleGrantedAuthority authority new SimpleGrantedAuthority(permission); authorities.add(authority); log.debug(authoritiesauthorities); } return authorities; } }在UserDetailsService中调用LoginUser的有参构造器将用户信息和权限信息注入到LoginUser实例中以供登录时AuthenticationManager认证管理器调用返回用户权限信息。登录接口获取到认证管理器的用户权限信息后会存入redis中在下一步中认证过滤器会取出相关权限信息存入到SecurityContextHolder中以供后续过滤器校验。Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { //查询用户信息 LambdaQueryWrapperUser queryWrapper new LambdaQueryWrapper(); queryWrapper.eq(User::getUserAccount,username); User user userMapper.selectOne(queryWrapper); if(usernull){ throw new RuntimeException(此用户username 不存在); } //查询对应的权限信息 ListPermissionSelectVo permissions userService.getUserByAccount(username).getPermissions(); ArrayListString permissionList new ArrayList(); for (PermissionSelectVo permission:permissions){ permissionList.add(permission.getPermissionCode()); } // 把数据封装成UserDetails return 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 { Override public 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 { Override public 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 { Override public 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(); // 关闭csrf4 总结本文通过引入security框架在登录接口中通过传入页面用户登录信息用户名、密码给UsernamePasswordAuthenticationToken来验证用户身份通过后返回token给前端并存入redis用户信息。其中验证的用户身份是通过实现接口UserDetailsService从数据库中获取用户信息并封装到LoginUser对象中。校验时通过添加认证过滤器完成从token获取用户信息并调用UsernamePasswordAuthenticationToken验证用户权限。security的完整配置文件如下所示Configuration EnableWebSecurity EnableMethodSecurity // 开启权限注解 public class SecurityConfig { Autowired private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;// 注入token校验过滤器 Autowired private AuthenticationEntryPoint authenticationEntryPoint; //注入授权异常处理器 Autowired private AccessDeniedHandler accessDeniedHandler; //注入认证异常处理器 // 配置密码加密方式全局自动按这个方式加密 Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } // 配置认证管理器 Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); } // security 配置 Bean public SecurityFilterChain httpSecurity(HttpSecurity http) throws Exception { // 1.前后端分离架构本身就是无状态token校验的所以天然防范csrf攻击可以关闭 http .csrf() .disable(); // 关闭csrf // .csrfTokenRepository(CookieCsrfTokenRepository. withHttpOnlyFalse()); // 将令牌保存到cookie中允许cookie前端获取 // 2.前后端分离架构不通过Session获取SecurityContext http .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(); } }
返回列表