目录
前提
强烈建议在学习完 2.x 版本的配置流程之后再阅读本文
推荐一个:视频教程
将要实现的功能
- 使用用户名+密码+验证码+记住我功能进行登陆
- CSRF校验
- 将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
提供数据库访问接口 - 由于我们实现了
UserDetailsPasswordService
,SpringSecurity
如果发现用户的密码加密方法过时或明文,将会自动修改密码。 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)
因为这些接口里都只有一个方法,且都需要类似的处理,我把它集中到一起实现,流程几乎是一致的:
- 设置
Content-Type
为application/json;charset=UTF-8
- 根据情况设置状态码
- 将返回结果写入到
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(记住我)
记住我功能,规定了:
- 从
request
的Attribute
中获取rememberMe
字段 - 当字段值为
TRUE_VALUES
表的成员时认为需要开启记住我功能
构造函数中
PersistentTokenRepository
会在后续提供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(登陆过滤器)
- 构造方法的参数都可以从容器获取,所以这里也直接注册到容器自动构造
- 继承了
UsernamePasswordAuthenticationFilter
,后续我们要用它替换默认的UsernamePasswordAuthenticationFilter
- 构造函数中,指定了: 1. 登陆成功和失败时的处理方法2. 记住我功能的组件3. 登陆使用的路径
attemptAuthentication
方法中规定了登陆流程: 1. 如果Content-Type
是Json,则从Body
中获取请求参数,否则从Form表单
中获取2. 从Session
的Attribute
中获取之前保存的验证码,和用户提供的验证码进行比对3. 把用户提供的rememberMe
字段放到request
的Attribute
中,供后续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(核心配置)
@Bean authenticationManager
提供了MyLoginFilter
需要的AuthenticationManager
@Bean daoAuthenticationProvider
提供了MyRememberMeServices
需要的PersistentTokenRepository
,其中setCreateTableOnStartup
方法在首次运行的时候需要解开注释让它自动建表@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();}}
完成
版权归原作者 银之石 所有, 如有侵权,请联系我们删除。