0


Spring Security 6.0(spring boot 3.0) 下认证配置流程

目录

前提

强烈建议在学习完 2.x 版本的配置流程之后再阅读本文

推荐一个:视频教程

将要实现的功能

  1. 使用用户名+密码+验证码+记住我功能进行登陆
  2. CSRF校验
  3. 将Session交给Redis管理,将记住我功能持久化到数据库

依赖(POM)

数据库操作部分省略了

<parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.0.0</version><relativePath/></parent><dependencies><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!--redis--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><!--session-redis--><dependency><groupId>org.springframework.session</groupId><artifactId>spring-session-data-redis</artifactId></dependency><!--验证码--><dependency><groupId>com.github.penggle</groupId><artifactId>kaptcha</artifactId><version>2.3.2</version></dependency><!--springdoc --><dependency><groupId>org.springdoc</groupId><artifactId>springdoc-openapi-starter-webmvc-ui</artifactId><version>2.0.0</version></dependency><!--knife4j - 接口文档UI--><dependency><groupId>com.github.xiaoymin</groupId><artifactId>knife4j-springdoc-ui</artifactId><!--在引用时请在maven中央仓库搜索3.X最新版本号--><version>3.0.3</version></dependency></dependencies>

注:结尾包含了 springdoc+knife4j 生成接口文档,示例代码中也包含了springdoc提供的注解。

示例代码

基础组件

验证码

生成配置(与视频教程中一致)

@ConfigurationpublicclassKaptchaConfig{@BeanpublicProducerkaptcha(){finalProperties properties =newProperties();//高度
        properties.setProperty("kaptcha.image.width","150");//宽度
        properties.setProperty("kaptcha.image.height","50");//可选字符串
        properties.setProperty("kaptcha.textproducer.char.string","0123456789");//验证码长度
        properties.setProperty("kaptcha.textproducer.char.length","4");finalDefaultKaptcha defaultKaptcha =newDefaultKaptcha();
        defaultKaptcha.setConfig(newConfig(properties));return defaultKaptcha;}}

接口

生成验证码,保存到

Session

Attribute

中,后续验证时也从这里取出,两个接口返回不同格式的验证码数据。

@Controller@RequestMapping("/sys/verifyCode")@RequiredArgsConstructor@Tag(name ="验证码接口")publicclassVerifyCodeController{publicstaticfinalStringVERIFY_CODE_KEY="vc";privatefinalProducer producer;@GetMapping("/base64")@Operation(summary ="Base64格式")@ResponseBodypublicRes<String>base64(@Parameter(hidden =true)HttpSession httpSession)throwsIOException{//生成验证码finalBufferedImage image =createImage(httpSession);//响应图片finalFastByteArrayOutputStream os =newFastByteArrayOutputStream();ImageIO.write(image,"jpeg", os);//返回 base64returnRes.of(Base64.encodeBase64String(os.toByteArray()));}@GetMapping("/image")@Operation(summary ="图片格式")publicvoidimage(@Parameter(hidden =true)HttpServletResponse response,@Parameter(hidden =true)HttpSession httpSession)throwsIOException{finalBufferedImage image =createImage(httpSession);//响应图片
        response.setContentType(MimeTypeUtils.IMAGE_JPEG_VALUE);ImageIO.write(image,"jpeg", response.getOutputStream());}privateBufferedImagecreateImage(HttpSession httpSession){//生成验证码finalString verifyCode = producer.createText();//保存到 session 中(或redis中)
        httpSession.setAttribute(VERIFY_CODE_KEY, verifyCode);//生成图片return producer.createImage(verifyCode);}}

MyUserDetailsServiceImpl(认证/权限信息)

  • 这里没什么特别的,根据用户名查询并返回用户的认证信息,SystemUserService提供数据库访问接口
  • 由于我们实现了UserDetailsPasswordServiceSpringSecurity如果发现用户的密码加密方法过时或明文,将会自动修改密码。
  • createUser方法是调用了SpringSecurity提供的User.UserBuilder构造了一个UserDetails
  • 因为尚未涉及到鉴权部分,这里在权限处直接给了一个空列表,这里如果不写会报错。
  • @Service直接注册到容器
@Service@RequiredArgsConstructorpublicclassMyUserDetailsServiceImplimplementsUserDetailsService,UserDetailsPasswordService{privatefinalSystemUserService systemUserService;/**
     * 当前用户
     * @return 当前用户
     */publicSystemUsercurrentUser(){finalAuthentication authentication =SecurityContextHolder.getContext().getAuthentication();finalString username =((UserDetails) authentication.getPrincipal()).getUsername();return systemUserService.getByUsername(username);}/**
     * 根据用户名查询用户的认证授权信息
     * @param username 用户名
     * @return org.springframework.security.core.userdetails.UserDetails
     * @throws UsernameNotFoundException 异常
     * @since 2022/12/6 15:03
     */@OverridepublicUserDetailsloadUserByUsername(String username)throwsUsernameNotFoundException{finalSystemUser systemUser = systemUserService.getByUsername(username);if(systemUser ==null){thrownewUsernameNotFoundException("用户不存在");}return systemUser.createUser().authorities(newArrayList<>()).build();}/**
     * 修改密码
     * @param user        用户
     * @param newPassword 新密码
     * @return UserDetails
     */@OverridepublicUserDetailsupdatePassword(UserDetails user,String newPassword){finalSystemUser systemUser = systemUserService.getByUsername(user.getUsername());
        systemUser.setPassword(newPassword);
        systemUserService.updateById(systemUser);return systemUser.createUser().authorities(newArrayList<>()).build();}}

MyAuthenticationHandler(Handler)

因为这些接口里都只有一个方法,且都需要类似的处理,我把它集中到一起实现,流程几乎是一致的:

  1. 设置Content-Typeapplication/json;charset=UTF-8
  2. 根据情况设置状态码
  3. 将返回结果写入到response

唯一需要注意的地方是,登陆成功后需要清理已使用过的验证码

注意:我们有两个地方需要用到这个对象,所以直接注册到容器中方便注入

@ComponentpublicclassMyAuthenticationHandlerimplementsAuthenticationSuccessHandler,AuthenticationFailureHandler,LogoutSuccessHandler,SessionInformationExpiredStrategy,AccessDeniedHandler,AuthenticationEntryPoint{publicstaticfinalStringAPPLICATION_JSON_CHARSET_UTF_8="application/json;charset=UTF-8";publicstaticfinalObjectMapperOBJECT_MAPPER=newObjectMapper();/**
     * 认证失败处理
     * @param request       that resulted in an <code>AuthenticationException</code>
     * @param response      so that the user agent can begin authentication
     * @param authException that caused the invocation
     * @throws IOException      异常
     * @throws ServletException 异常
     */@Overridepublicvoidcommence(HttpServletRequest request,HttpServletResponse response,AuthenticationException e)throwsIOException,ServletException{String detailMessage = e.getClass().getSimpleName()+" "+ e.getLocalizedMessage();if(e instanceofInsufficientAuthenticationException){
            detailMessage ="请登陆后再访问";}
        response.setContentType(APPLICATION_JSON_CHARSET_UTF_8);
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.getWriter().println(OBJECT_MAPPER.writeValueAsString(Res.of(detailMessage,"认证异常")));}/**
     * 权限不足时的处理
     * @param request               that resulted in an <code>AccessDeniedException</code>
     * @param response              so that the user agent can be advised of the failure
     * @param accessDeniedException that caused the invocation
     */@Overridepublicvoidhandle(HttpServletRequest request,HttpServletResponse response,AccessDeniedException accessDeniedException)throwsIOException,ServletException{String detailMessage =null;if(accessDeniedException instanceofMissingCsrfTokenException){
            detailMessage ="缺少CSRF TOKEN,请从表单或HEADER传入";}elseif(accessDeniedException instanceofInvalidCsrfTokenException){
            detailMessage ="无效的CSRF TOKEN";}elseif(accessDeniedException instanceofCsrfException){
            detailMessage = accessDeniedException.getLocalizedMessage();}elseif(accessDeniedException instanceofAuthorizationServiceException){
            detailMessage =AuthorizationServiceException.class.getSimpleName()+" "+ accessDeniedException.getLocalizedMessage();}
        response.setContentType(APPLICATION_JSON_CHARSET_UTF_8);
        response.setStatus(HttpStatus.FORBIDDEN.value());
        response.getWriter().println(OBJECT_MAPPER.writeValueAsString(Res.of(detailMessage,"禁止访问")));}/**
     * 认证失败时的处理
     */@OverridepublicvoidonAuthenticationFailure(HttpServletRequest request,HttpServletResponse response,AuthenticationException exception)throwsIOException,ServletException{
        response.setContentType(APPLICATION_JSON_CHARSET_UTF_8);
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.getWriter().println(OBJECT_MAPPER.writeValueAsString(Res.of(exception.getLocalizedMessage(),"登陆失败")));}/**
     * 认证成功时的处理
     */@OverridepublicvoidonAuthenticationSuccess(HttpServletRequest request,HttpServletResponse response,Authentication authentication)throwsIOException,ServletException{
        response.setContentType(APPLICATION_JSON_CHARSET_UTF_8);
        response.setStatus(HttpStatus.OK.value());// SecurityContext在设置Authentication的时候并不会自动写入Session,读的时候却会根据Session判断,所以需要手动写入一次,否则下一次刷新时SecurityContext是新创建的实例。//  https://yangruoyu.blog.csdn.net/article/details/128276473
        request.getSession().setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,SecurityContextHolder.getContext());
        response.getWriter().println(OBJECT_MAPPER.writeValueAsString(Res.of(MyUserDetails.of(authentication),"登陆成功")));//清理使用过的验证码
        request.getSession().removeAttribute(VERIFY_CODE_KEY);}/**
     * 会话过期处理
     * @throws IOException      异常
     * @throws ServletException 异常
     */@OverridepublicvoidonExpiredSessionDetected(SessionInformationExpiredEvent event)throwsIOException,ServletException{String message ="该账号已从其他设备登陆,如果不是您自己的操作请及时修改密码";finalHttpServletResponse response = event.getResponse();
        response.setContentType(APPLICATION_JSON_CHARSET_UTF_8);
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.getWriter().println(OBJECT_MAPPER.writeValueAsString(Res.of(event.getSessionInformation(), message)));}/**
     * 登出成功处理
     * @param request        请求
     * @param response       响应
     * @param authentication 认证信息
     * @throws IOException      异常
     * @throws ServletException 异常
     */@OverridepublicvoidonLogoutSuccess(HttpServletRequest request,HttpServletResponse response,Authentication authentication)throwsIOException,ServletException{
        response.setContentType(APPLICATION_JSON_CHARSET_UTF_8);
        response.setStatus(HttpStatus.OK.value());
        response.getWriter().println(OBJECT_MAPPER.writeValueAsString(Res.of(MyUserDetails.of(authentication),"注销成功")));}}

MyRememberMeServices(记住我)

记住我功能,规定了:

  1. requestAttribute中获取rememberMe字段
  2. 当字段值为TRUE_VALUES表的成员时认为需要开启记住我功能

构造函数中

  1. PersistentTokenRepository会在后续提供
  2. UserDetailsService已在前文提供

注意:我们有两个地方需要用到这个对象,所以直接注册到容器中方便注入

@ComponentpublicclassMyRememberMeServicesextendsPersistentTokenBasedRememberMeServices{publicstaticfinalStringREMEMBER_ME_KEY="rememberMe";publicstaticfinalList<String>TRUE_VALUES=List.of("true","yes","on","1");publicMyRememberMeServices(UserDetailsService userDetailsService,PersistentTokenRepository tokenRepository){super(UUID.randomUUID().toString(), userDetailsService, tokenRepository);}@OverrideprotectedbooleanrememberMeRequested(HttpServletRequest request,String parameter){finalString rememberMe =(String) request.getAttribute(REMEMBER_ME_KEY);if(rememberMe !=null){for(String trueValue :TRUE_VALUES){if(trueValue.equalsIgnoreCase(rememberMe)){returntrue;}}}returnsuper.rememberMeRequested(request, parameter);}}

核心组件

MyLoginFilter(登陆过滤器)

  1. 构造方法的参数都可以从容器获取,所以这里也直接注册到容器自动构造
  2. 继承了UsernamePasswordAuthenticationFilter,后续我们要用它替换默认的UsernamePasswordAuthenticationFilter
  3. 构造函数中,指定了: 1. 登陆成功和失败时的处理方法2. 记住我功能的组件3. 登陆使用的路径
  4. attemptAuthentication方法中规定了登陆流程: 1. 如果Content-Type是Json,则从Body中获取请求参数,否则从Form表单中获取2. 从SessionAttribute中获取之前保存的验证码,和用户提供的验证码进行比对3. 把用户提供的rememberMe字段放到requestAttribute中,供后续MyRememberMeServices获取4. 结尾部分来自父类,照抄过来的。
@ComponentpublicclassMyLoginFilterextendsUsernamePasswordAuthenticationFilter{privatefinalObjectMapper objectMapper =newObjectMapper();publicMyLoginFilter(AuthenticationManager authenticationManager,MyAuthenticationHandler authenticationHandler,MyRememberMeServices rememberMeServices)throwsException{super(authenticationManager);setAuthenticationFailureHandler(authenticationHandler);setAuthenticationSuccessHandler(authenticationHandler);//rememberMesetRememberMeServices(rememberMeServices);//登陆使用的路径setFilterProcessesUrl("/sys/user/login");}privatestaticbooleanisContentTypeJson(HttpServletRequest request){finalString contentType = request.getContentType();returnAPPLICATION_JSON_CHARSET_UTF_8.equalsIgnoreCase(contentType)||MimeTypeUtils.APPLICATION_JSON_VALUE.equalsIgnoreCase(contentType);}@OverridepublicAuthenticationattemptAuthentication(HttpServletRequest request,HttpServletResponse response)throwsAuthenticationException{if(!HttpMethod.POST.name().equalsIgnoreCase(request.getMethod())){thrownewAuthenticationServiceException("Authentication method not supported: "+ request.getMethod());}String username =null;String password =null;String verifyCode =null;String rememberMe =null;if(isContentTypeJson(request)){try{Map<String,String> map = objectMapper.readValue(request.getInputStream(),newTypeReference<>(){});
                username = map.get(getUsernameParameter());
                password = map.get(getPasswordParameter());
                verifyCode = map.get(VERIFY_CODE_KEY);
                rememberMe = map.get(MyRememberMeServices.REMEMBER_ME_KEY);}catch(IOException e){
                e.printStackTrace();}}else{
            username =obtainUsername(request);
            password =obtainPassword(request);
            verifyCode = request.getParameter(VERIFY_CODE_KEY);
            rememberMe = request.getParameter(MyRememberMeServices.REMEMBER_ME_KEY);}//校验验证码finalString vc =(String) request.getSession().getAttribute(VERIFY_CODE_KEY);if(vc ==null){thrownewBadCredentialsException("验证码不存在,请先获取验证码");}elseif(verifyCode ==null||"".equals(verifyCode)){thrownewBadCredentialsException("请输入验证码");}elseif(!vc.equalsIgnoreCase(verifyCode)){thrownewBadCredentialsException("验证码错误");}//将 rememberMe 状态存入 attr中if(!ObjectUtils.isEmpty(rememberMe)){
            request.setAttribute(MyRememberMeServices.REMEMBER_ME_KEY, rememberMe);}

        username =(username !=null)? username.trim():"";
        password =(password !=null)? password :"";UsernamePasswordAuthenticationToken authRequest =UsernamePasswordAuthenticationToken.unauthenticated(username, password);// Allow subclasses to set the "details" propertysetDetails(request, authRequest);returnthis.getAuthenticationManager().authenticate(authRequest);}}

MySecurityConfig(核心配置)

  1. @Bean authenticationManager 提供了MyLoginFilter需要的AuthenticationManager
  2. @Bean daoAuthenticationProvider提供了MyRememberMeServices需要的PersistentTokenRepository,其中setCreateTableOnStartup方法在首次运行的时候需要解开注释让它自动建表
  3. @Bean securityFilterChain核心中的核心,2.x版本中对HttpSecurity http的配置都需要移动到这里,这里我们配置了: 1. 路径配置,这里把接口文档和验证码的路径进行了放行,其他请求都需要认证。登陆请求并不受它影响不需要专门配置。2. 用自定义的MyLoginFilter替换了默认的UsernamePasswordAuthenticationFilter,注意原本的http.formLogin()不要再写了,否则将可以通过/login绕过验证码登陆3. 登出配置,指定了路径,和成功登出的处理方法4. csrf验证,注意这里比2.x版本需要多写一句.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())5. 会话管理,配置了只允许一个端登陆,不需要配置sessionRegistry了,会自动注入,当然手动配置也是可以的,但是容器里不会自动创建了,需要手动传一个new SpringSessionBackedSessionRegistry<>(new RedisIndexedSessionRepository(redisTemplate)),其中redisTemplate需要为RedisTemplate<String,Object>6. 记住我功能,注意,这里和MyLoginFilter里的两次配置缺一不可。7. 权限不足时的处理
@Configuration@RequiredArgsConstructorpublicclassMySecurityConfig{/**
     * 接口文档放行
     */publicstaticfinalList<String>DOC_WHITE_LIST=List.of("/doc.html","/webjars/**","/v3/api-docs/**");/**
     * 测试接口放行
     */publicstaticfinalList<String>TEST_WHITE_LIST=List.of("/test/**");/**
     * 验证码放行
     */publicstaticfinalList<String>VERIFY_CODE_WHITE_LIST=List.of("/sys/verifyCode/**");/**
     * 获取AuthenticationManager(认证管理器),登录时认证使用
     */@BeanpublicAuthenticationManagerauthenticationManager(AuthenticationConfiguration authenticationConfiguration)throwsException{return authenticationConfiguration.getAuthenticationManager();}/**
     * 允许抛出用户不存在的异常
     * @param myUserDetailsService myUserDetailsService
     * @return DaoAuthenticationProvider
     */@BeanpublicDaoAuthenticationProviderdaoAuthenticationProvider(MyUserDetailsServiceImpl myUserDetailsService){finalDaoAuthenticationProvider provider =newDaoAuthenticationProvider();
        provider.setUserDetailsService(myUserDetailsService);
        provider.setUserDetailsPasswordService(myUserDetailsService);
        provider.setHideUserNotFoundExceptions(false);return provider;}/**
     * 自定义RememberMe服务token持久化仓库
     */@BeanpublicPersistentTokenRepositorypersistentTokenRepository(DataSource datasource){finalJdbcTokenRepositoryImpl tokenRepository =newJdbcTokenRepositoryImpl();//设置数据源
        tokenRepository.setDataSource(datasource);//第一次启动的时候建表//        tokenRepository.setCreateTableOnStartup(true);return tokenRepository;}@BeanpublicSecurityFilterChainsecurityFilterChain(HttpSecurity http,MyLoginFilter loginFilter,MyAuthenticationHandler authenticationHandler,MyRememberMeServices rememberMeServices
    )throwsException{//路径配置
        http.authorizeHttpRequests().requestMatchers(HttpMethod.GET,DOC_WHITE_LIST.toArray(newString[0])).permitAll().requestMatchers(HttpMethod.GET,VERIFY_CODE_WHITE_LIST.toArray(newString[0])).permitAll()//                .requestMatchers(HttpMethod.GET, TEST_WHITE_LIST.toArray(new String[0])).permitAll().anyRequest().authenticated();//登陆
        http.addFilterAt(loginFilter,UsernamePasswordAuthenticationFilter.class);//配置自定义登陆流程后需要关闭 否则可以使用原有登陆方式//登出
        http.logout().logoutUrl("/sys/user/logout").logoutSuccessHandler(authenticationHandler);//禁用 csrf//        http.csrf().disable();//csrf验证 存储到Cookie中
        http.csrf().csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()).csrfTokenRequestHandler(newCsrfTokenRequestAttributeHandler());//会话管理
        http.sessionManagement().maximumSessions(1).expiredSessionStrategy(authenticationHandler)//引入redis-session依赖后已不再需要手动配置 sessionRegistry//                .sessionRegistry(new SpringSessionBackedSessionRegistry<>(new RedisIndexedSessionRepository(RedisConfig.createRedisTemplate())))//禁止后登陆挤下线//               .maxSessionsPreventsLogin(true);//rememberMe
        http.rememberMe().rememberMeServices(rememberMeServices);// 权限不足时的处理
        http.exceptionHandling().accessDeniedHandler(authenticationHandler).authenticationEntryPoint(authenticationHandler);return http.build();}}

完成

标签: spring boot spring java

本文转载自: https://blog.csdn.net/hjg719/article/details/128302584
版权归原作者 银之石 所有, 如有侵权,请联系我们删除。

“Spring Security 6.0(spring boot 3.0) 下认证配置流程”的评论:

还没有评论