文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

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

2023-08-23 07:07

关注

前提

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

推荐一个:视频教程

将要实现的功能

  1. 使用用户名+密码+验证码+记住我功能进行登陆
  2. CSRF校验
  3. 将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;    }}

接口

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

@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(认证/权限信息)

@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)

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

  1. 设置Content-Typeapplication/json;charset=UTF-8
  2. 根据情况设置状态码
  3. 将返回结果写入到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(记住我)

记住我功能,规定了:

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

构造函数中

  1. PersistentTokenRepository会在后续提供
  2. 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(登陆过滤器)

  1. 构造方法的参数都可以从容器获取,所以这里也直接注册到容器自动构造
  2. 继承了UsernamePasswordAuthenticationFilter,后续我们要用它替换默认的UsernamePasswordAuthenticationFilter
  3. 构造函数中,指定了:
    1. 登陆成功和失败时的处理方法
    2. 记住我功能的组件
    3. 登陆使用的路径
  4. attemptAuthentication方法中规定了登陆流程:
    1. 如果Content-Type是Json,则从Body中获取请求参数,否则从Form表单中获取
    2. SessionAttribute中获取之前保存的验证码,和用户提供的验证码进行比对
    3. 把用户提供的rememberMe字段放到requestAttribute中,供后续MyRememberMeServices获取
    4. 结尾部分来自父类,照抄过来的。
@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(核心配置)

  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
    6. 记住我功能,注意,这里和MyLoginFilter里的两次配置缺一不可。
    7. 权限不足时的处理
@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();    }}

完成

来源地址:https://blog.csdn.net/hjg719/article/details/128302584

阅读原文内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     807人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     351人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     314人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     433人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     221人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯