目录
前提
强烈建议在学习完 2.x 版本的配置流程之后再阅读本文
推荐一个:视频教程
将要实现的功能
- 使用用户名+密码+验证码+记住我功能进行登陆
- CSRF校验
- 将Session交给Redis管理,将记住我功能持久化到数据库
依赖(POM)
数据库操作部分省略了
<parent><groupId>org.springframework.bootgroupId><artifactId>spring-boot-starter-parentartifactId><version>3.0.0version><relativePath/>parent><dependencies> <dependency> <groupId>org.projectlombokgroupId> <artifactId>lombokartifactId> <optional>trueoptional> dependency><dependency><groupId>org.springframework.bootgroupId><artifactId>spring-boot-starter-securityartifactId>dependency><dependency><groupId>org.springframework.bootgroupId><artifactId>spring-boot-starter-webartifactId>dependency><dependency><groupId>org.springframework.bootgroupId><artifactId>spring-boot-starter-data-redisartifactId>dependency><dependency><groupId>org.springframework.sessiongroupId><artifactId>spring-session-data-redisartifactId>dependency><dependency><groupId>com.github.pengglegroupId><artifactId>kaptchaartifactId><version>2.3.2version>dependency><dependency><groupId>org.springdocgroupId><artifactId>springdoc-openapi-starter-webmvc-uiartifactId><version>2.0.0version>dependency><dependency><groupId>com.github.xiaoymingroupId><artifactId>knife4j-springdoc-uiartifactId><version>3.0.3version>dependency>dependencies>
注:结尾包含了 springdoc+knife4j 生成接口文档,示例代码中也包含了springdoc提供的注解。
示例代码
基础组件
验证码
生成配置(与视频教程中一致)
@Configurationpublic class KaptchaConfig { @Bean public Producer kaptcha() { final Properties properties = new Properties(); //高度 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"); final DefaultKaptcha defaultKaptcha = new DefaultKaptcha(); defaultKaptcha.setConfig(new Config(properties)); return defaultKaptcha; }}
接口
生成验证码,保存到Session
的Attribute
中,后续验证时也从这里取出,两个接口返回不同格式的验证码数据。
@Controller@RequestMapping("/sys/verifyCode")@RequiredArgsConstructor@Tag(name = "验证码接口")public class VerifyCodeController { public static final String VERIFY_CODE_KEY = "vc"; private final Producer producer; @GetMapping("/base64") @Operation(summary = "Base64格式") @ResponseBody public Res<String> base64(@Parameter(hidden = true) HttpSession httpSession) throws IOException { //生成验证码 final BufferedImage image = createImage(httpSession); //响应图片 final FastByteArrayOutputStream os = new FastByteArrayOutputStream(); ImageIO.write(image, "jpeg", os); //返回 base64 return Res.of(Base64.encodeBase64String(os.toByteArray())); } @GetMapping("/image") @Operation(summary = "图片格式") public void image(@Parameter(hidden = true) HttpServletResponse response, @Parameter(hidden = true) HttpSession httpSession) throws IOException { final BufferedImage image = createImage(httpSession); //响应图片 response.setContentType(MimeTypeUtils.IMAGE_JPEG_VALUE); ImageIO.write(image, "jpeg", response.getOutputStream()); } private BufferedImage createImage(HttpSession httpSession) { //生成验证码 final String 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@RequiredArgsConstructorpublic class MyUserDetailsServiceImpl implements UserDetailsService, UserDetailsPasswordService { private final SystemUserService systemUserService; public SystemUser currentUser() { final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); final String username = ((UserDetails) authentication.getPrincipal()).getUsername(); return systemUserService.getByUsername(username); } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { final SystemUser systemUser = systemUserService.getByUsername(username); if (systemUser == null) { throw new UsernameNotFoundException("用户不存在"); } return systemUser.createUser() .authorities(new ArrayList<>()) .build(); } @Override public UserDetails updatePassword(UserDetails user, String newPassword) { final SystemUser systemUser = systemUserService.getByUsername(user.getUsername()); systemUser.setPassword(newPassword); systemUserService.updateById(systemUser); return systemUser.createUser() .authorities(new ArrayList<>()) .build(); }}
MyAuthenticationHandler(Handler)
因为这些接口里都只有一个方法,且都需要类似的处理,我把它集中到一起实现,流程几乎是一致的:
- 设置
Content-Type
为application/json;charset=UTF-8
- 根据情况设置状态码
- 将返回结果写入到
response
唯一需要注意的地方是,登陆成功后需要清理已使用过的验证码
注意:我们有两个地方需要用到这个对象,所以直接注册到容器中方便注入
@Componentpublic class MyAuthenticationHandler implements AuthenticationSuccessHandler , AuthenticationFailureHandler , LogoutSuccessHandler , SessionInformationExpiredStrategy , AccessDeniedHandler, AuthenticationEntryPoint { public static final String APPLICATION_JSON_CHARSET_UTF_8 = "application/json;charset=UTF-8"; public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException { String detailMessage = e.getClass().getSimpleName() + " " + e.getLocalizedMessage(); if (e instanceof InsufficientAuthenticationException) { detailMessage = "请登陆后再访问"; } response.setContentType(APPLICATION_JSON_CHARSET_UTF_8); response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter().println(OBJECT_MAPPER.writeValueAsString(Res.of(detailMessage, "认证异常"))); } @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { String detailMessage = null; if (accessDeniedException instanceof MissingCsrfTokenException) { detailMessage = "缺少CSRF TOKEN,请从表单或HEADER传入"; } else if (accessDeniedException instanceof InvalidCsrfTokenException) { detailMessage = "无效的CSRF TOKEN"; } else if (accessDeniedException instanceof CsrfException) { detailMessage = accessDeniedException.getLocalizedMessage(); } else if (accessDeniedException instanceof AuthorizationServiceException) { 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, "禁止访问"))); } @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { response.setContentType(APPLICATION_JSON_CHARSET_UTF_8); response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter().println(OBJECT_MAPPER.writeValueAsString(Res.of(exception.getLocalizedMessage(), "登陆失败"))); } @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, 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); } @Override public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException { String message = "该账号已从其他设备登陆,如果不是您自己的操作请及时修改密码"; final HttpServletResponse 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))); } @Override public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, 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
已在前文提供
注意:我们有两个地方需要用到这个对象,所以直接注册到容器中方便注入
@Componentpublic class MyRememberMeServices extends PersistentTokenBasedRememberMeServices { public static final String REMEMBER_ME_KEY = "rememberMe"; public static final List<String> TRUE_VALUES = List.of("true", "yes", "on", "1"); public MyRememberMeServices(UserDetailsService userDetailsService, PersistentTokenRepository tokenRepository) { super(UUID.randomUUID().toString(), userDetailsService, tokenRepository); } @Override protected boolean rememberMeRequested(HttpServletRequest request, String parameter) { final String rememberMe = (String) request.getAttribute(REMEMBER_ME_KEY); if (rememberMe != null) { for (String trueValue : TRUE_VALUES) { if (trueValue.equalsIgnoreCase(rememberMe)) { return true; } } } return super.rememberMeRequested(request, parameter); }}
核心组件
MyLoginFilter(登陆过滤器)
- 构造方法的参数都可以从容器获取,所以这里也直接注册到容器自动构造
- 继承了
UsernamePasswordAuthenticationFilter
,后续我们要用它替换默认的UsernamePasswordAuthenticationFilter
- 构造函数中,指定了:
- 登陆成功和失败时的处理方法
- 记住我功能的组件
- 登陆使用的路径
attemptAuthentication
方法中规定了登陆流程:- 如果
Content-Type
是Json,则从Body
中获取请求参数,否则从Form表单
中获取 - 从
Session
的Attribute
中获取之前保存的验证码,和用户提供的验证码进行比对 - 把用户提供的
rememberMe
字段放到request
的Attribute
中,供后续MyRememberMeServices
获取 - 结尾部分来自父类,照抄过来的。
- 如果
@Componentpublic class MyLoginFilter extends UsernamePasswordAuthenticationFilter { private final ObjectMapper objectMapper = new ObjectMapper(); public MyLoginFilter(AuthenticationManager authenticationManager, MyAuthenticationHandler authenticationHandler, MyRememberMeServices rememberMeServices) throws Exception { super(authenticationManager); setAuthenticationFailureHandler(authenticationHandler); setAuthenticationSuccessHandler(authenticationHandler); //rememberMe setRememberMeServices(rememberMeServices); //登陆使用的路径 setFilterProcessesUrl("/sys/user/login"); } private static boolean isContentTypeJson(HttpServletRequest request) { final String contentType = request.getContentType(); return APPLICATION_JSON_CHARSET_UTF_8.equalsIgnoreCase(contentType) || MimeTypeUtils.APPLICATION_JSON_VALUE.equalsIgnoreCase(contentType); } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (!HttpMethod.POST.name().equalsIgnoreCase(request.getMethod())) { throw new AuthenticationServiceException("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(), new TypeReference<>() { }); 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); } //校验验证码 final String vc = (String) request.getSession().getAttribute(VERIFY_CODE_KEY); if (vc == null) { throw new BadCredentialsException("验证码不存在,请先获取验证码"); } else if (verifyCode == null || "".equals(verifyCode)) { throw new BadCredentialsException("请输入验证码"); } else if (!vc.equalsIgnoreCase(verifyCode)) { throw new BadCredentialsException("验证码错误"); } //将 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" property setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); }}
MySecurityConfig(核心配置)
@Bean authenticationManager
提供了MyLoginFilter
需要的AuthenticationManager
@Bean daoAuthenticationProvider
提供了MyRememberMeServices
需要的PersistentTokenRepository
,其中setCreateTableOnStartup
方法在首次运行的时候需要解开注释让它自动建表@Bean securityFilterChain
核心中的核心,2.x版本中对HttpSecurity http
的配置都需要移动到这里,这里我们配置了:- 路径配置,这里把接口文档和验证码的路径进行了放行,其他请求都需要认证。登陆请求并不受它影响不需要专门配置。
- 用自定义的
MyLoginFilter
替换了默认的UsernamePasswordAuthenticationFilter
,注意原本的http.formLogin()
不要再写了,否则将可以通过/login
绕过验证码登陆 - 登出配置,指定了路径,和成功登出的处理方法
- csrf验证,注意这里比2.x版本需要多写一句
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
- 会话管理,配置了只允许一个端登陆,不需要配置
sessionRegistry
了,会自动注入,当然手动配置也是可以的,但是容器里不会自动创建了,需要手动传一个new SpringSessionBackedSessionRegistry<>(new RedisIndexedSessionRepository(redisTemplate))
,其中redisTemplate
需要为RedisTemplate
- 记住我功能,注意,这里和
MyLoginFilter
里的两次配置缺一不可。 - 权限不足时的处理
@Configuration@RequiredArgsConstructorpublic class MySecurityConfig { public static final List<String> DOC_WHITE_LIST = List.of("/doc.html", "/webjars public static final List<String> TEST_WHITE_LIST = List.of("/test public static final List<String> VERIFY_CODE_WHITE_LIST = List.of("/sys/verifyCode @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); } @Bean public DaoAuthenticationProvider daoAuthenticationProvider(MyUserDetailsServiceImpl myUserDetailsService) { final DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); provider.setUserDetailsService(myUserDetailsService); provider.setUserDetailsPasswordService(myUserDetailsService); provider.setHideUserNotFoundExceptions(false); return provider; } @Bean public PersistentTokenRepository persistentTokenRepository(DataSource datasource) { final JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl(); //设置数据源 tokenRepository.setDataSource(datasource); //第一次启动的时候建表// tokenRepository.setCreateTableOnStartup(true); return tokenRepository; } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http, MyLoginFilter loginFilter, MyAuthenticationHandler authenticationHandler, MyRememberMeServices rememberMeServices ) throws Exception { //路径配置 http.authorizeHttpRequests() .requestMatchers(HttpMethod.GET, DOC_WHITE_LIST.toArray(new String[0])).permitAll() .requestMatchers(HttpMethod.GET, VERIFY_CODE_WHITE_LIST.toArray(new String[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(new CsrfTokenRequestAttributeHandler()) ; //会话管理 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(); }}
完成