前后端分离下的-SpringSecurity

news2024/10/6 6:52:58

前后端分离下的SpringSecurity

项目创建

  • 使用SpringBoot初始化器创建SpringBoot项目

  • 修改项目依赖

    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
        <parent>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-parent</artifactId>
            <version>2.7.9</version>
            <relativePath/> <!-- lookup parent from repository -->
        </parent>
        <groupId>com.example</groupId>
        <artifactId>baizhi-security</artifactId>
        <version>0.0.1-SNAPSHOT</version>
    
        <properties>
            <java.version>1.8</java.version>
        </properties>
    
        <dependencies>
            <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>
            <dependency>
                <groupId>org.mybatis.spring.boot</groupId>
                <artifactId>mybatis-spring-boot-starter</artifactId>
                <version>2.3.0</version>
            </dependency>
    
            <dependency>
                <groupId>com.mysql</groupId>
                <artifactId>mysql-connector-j</artifactId>
                <scope>runtime</scope>
            </dependency>
            <dependency>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
                <optional>true</optional>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-test</artifactId>
                <scope>test</scope>
            </dependency>
            <dependency>
                <groupId>org.springframework.security</groupId>
                <artifactId>spring-security-test</artifactId>
                <scope>test</scope>
            </dependency>
            <dependency>
                <groupId>com.alibaba</groupId>
                <artifactId>druid-spring-boot-starter</artifactId>
                <version>1.2.15</version>
            </dependency>
            <!-- 验证码 -->
            <dependency>
                <groupId>com.github.penggle</groupId>
                <artifactId>kaptcha</artifactId>
                <version>2.3.2</version>
            </dependency>
    
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-data-redis</artifactId>
            </dependency>
        </dependencies>
    
        <build>
            <plugins>
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                    <configuration>
                        <excludes>
                            <exclude>
                                <groupId>org.projectlombok</groupId>
                                <artifactId>lombok</artifactId>
                            </exclude>
                        </excludes>
                    </configuration>
                </plugin>
            </plugins>
        </build>
    
    </project>
    
    
  • Java环境

    JDK 1.8
  • YAML配置

    spring:
      datasource:
        druid:
          driver-class-name: com.mysql.cj.jdbc.Driver
          url: jdbc:mysql://localhost:3306/security?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
          username: root
          password: root
      redis:
        host: 192.168.47.128 # 虚拟机 ip
        port: 6379 # (配置过主从复制)必须使用 master 机器 的端口号
        database: 0 # 选择的数据库实例
        connect-timeout: 10000 # 超时时间
    
    mybatis:
      type-aliases-package: com.example.baizhisecurity.entity
      mapper-locations: com/example/baizhisecurity/mapper/*Mapper.xml
    logging:
      level:
        com.example.baizhisecurity: debug # 查看 SQL
    
    # 修改服务器的过期时间为 1 分钟
    server:
      servlet:
        session:
          timeout: 1 
      error: # 自定义错误页面相关的配置
        whitelabel:
          enabled: false # 关闭默认的显示
        path: /error # 定义错误的路径
      resources: # 资源映射
        add-mappings: true
    

数据库表

  • user

    user
    iWufsH.png
    -- {noop} 是 SpringSecurity 密码无加密的 id
    INSERT INTO `user`  VALUES (1, 'root', '{bcrypt}$2a$10$f1Y3k626cs1ict.wKKWNDuFwk46.YkcdIx/Ib/wHEsnoW7Uo/1Nb6', 1, 1, 1, 1);
    INSERT INTO `user`  VALUES (2, 'admin', '{noop}123', 1, 1, 1, 1);
    INSERT INTO `user`  VALUES (3, 'coder-itl', '{noop}123', 1, 1, 1, 1);
    
  • role

    role
    INSERT INTO `security`.`role` (`id`, `name`, `name_zh`) VALUES (1, 'ROLE_product', '商品管理员');
    INSERT INTO `security`.`role` (`id`, `name`, `name_zh`) VALUES (2, 'ROLE_admin', '系统管理员');
    INSERT INTO `security`.`role` (`id`, `name`, `name_zh`) VALUES (3, 'ROLE_user', '用户管理员');
    
  • user_role

    INSERT INTO `security`.`user_role` (`id`, `uid`, `rid`) VALUES (1, 1, 1);
    INSERT INTO `security`.`user_role` (`id`, `uid`, `rid`) VALUES (2, 1, 2);
    INSERT INTO `security`.`user_role` (`id`, `uid`, `rid`) VALUES (3, 2, 2);
    INSERT INTO `security`.`user_role` (`id`, `uid`, `rid`) VALUES (4, 3, 3);
    

实体类

  • 用户实体

    package com.example.baizhisecurity.entity;
    
    
    import lombok.AllArgsConstructor;
    import lombok.Data;
    import lombok.NoArgsConstructor;
    import org.springframework.security.core.GrantedAuthority;
    import org.springframework.security.core.authority.SimpleGrantedAuthority;
    import org.springframework.security.core.userdetails.UserDetails;
    
    import java.util.*;
    
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public class User implements UserDetails {
        private Integer id;
        private String username;
        private String password;
        private Boolean enabled;
        private Boolean accountNonExpired;
        private Boolean accountNonLocked;
        private Boolean credentialsNonExpired;
        private List<Role> roles = new ArrayList<>();
    
        // 权限集合
        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            Set<SimpleGrantedAuthority> authorities = new HashSet<>();
            roles.forEach(role -> {
                SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority(role.getName());
                authorities.add(simpleGrantedAuthority);
            });
            return authorities;
        }
    
        @Override
        public String getPassword() {
            return password;
        }
    
        @Override
        public String getUsername() {
            return username;
        }
    
        @Override
        public boolean isAccountNonExpired() {
            return accountNonExpired;
        }
    
        @Override
        public boolean isAccountNonLocked() {
            return accountNonLocked;
        }
    
        @Override
        public boolean isCredentialsNonExpired() {
            return credentialsNonExpired;
        }
    
        @Override
        public boolean isEnabled() {
            return enabled;
        }
    }
    
  • 角色实体

    package com.example.baizhisecurity.entity;
    
    import lombok.AllArgsConstructor;
    import lombok.Data;
    import lombok.NoArgsConstructor;
    
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public class Role {
        private Integer id;
        private String name;
        private String nameZh;
    }
    

控制器

  • 测试控制器类

    @RestController
    public class HelloController {
        @GetMapping("/hello")
        public ResultModel hello() {
            return ResultModel.success(HttpStatus.OK.value(), "访问成功", "Hello developer,You successfully retrieved the data!");
        }
    }
    

JSON 响应和统一数据返回

  • 响应

    public class ResponseUtil {
        public static void out(HttpServletResponse response,ResultModel resultModel){
            ObjectMapper objectMapper = new ObjectMapper();
            // 设置响应的状态为 200
            response.setStatus(HttpStatus.OK.value());
            // 设置响应的格式为 JSON 格式
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            try {
                // 使用jackson,把json格式的resultModel写入到response的输出流中
                objectMapper.writeValue(response.getOutputStream(),resultModel);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    
  • 统一数据返回模型

    package com.example.baizhisecurity.common;
    
    import lombok.Data;
    
    import java.io.Serializable;
    
    @Data
    public class ResultModel<T> implements Serializable {
        // 状态码
        private int code; // 1000表示成功 401 表示认证失败
        // 消息
        private String message;
        // 数据
        private T data;
    
    
        private static ResultModel resultModel = new ResultModel();
    
    
        public static ResultModel success(String message) {
            resultModel.setCode(1000);
            resultModel.setMessage(message);
            resultModel.setData(null);
            return resultModel;
        }
    
        public static ResultModel success(Object data) {
            resultModel.setCode(1000);
            resultModel.setMessage("success");
            resultModel.setData(data);
            return resultModel;
        }
    
        public static ResultModel success(String message, Object data) {
            resultModel.setCode(1000);
            resultModel.setMessage(message);
            resultModel.setData(data);
            return resultModel;
        }
    
        public static ResultModel success(Integer code, String message) {
            resultModel.setCode(1000);
            resultModel.setMessage(message);
            return resultModel;
        }
    
        public static ResultModel success(Integer code, String message, Object data) {
            resultModel.setCode(code);
            resultModel.setMessage(message);
            resultModel.setData(data);
            return resultModel;
        }
        
        public static ResultModel error() {
            resultModel.setCode(500);
            resultModel.setMessage("error");
            return resultModel;
        }
    
        public static ResultModel error(int code, String message) {
            resultModel.setCode(code);
            resultModel.setMessage(message);
            return resultModel;
        }
    }
    

SpringSecurity 的配置

配置类

  • 配置类的实现

    package com.example.baizhisecurity.config;
    
    import org.springframework.web.cors.CorsConfiguration;
    import org.springframework.web.cors.CorsConfigurationSource;
    import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
    
    @Slf4j
    @EnableWebSecurity
    @EnableGlobalMethodSecurity(prePostEnabled = true)
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
        // redis
        private final StringRedisTemplate redisTemplate;
        // 登录成功处理
        private final MyLogoutSuccessHandler myLogoutSuccessHandler;
        // 自定义认证成功处理
        private final MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;
        // 自定义认证失败处理
        private final MyAuthenticationFailureHandler myAuthenticationFailureHandler;
        // 自定义认证异常处理
        private final MyAuthenticationEntryPoint myAuthenticationEntryPoint;
        // RememberMe 需要的数据源
        private final DataSource dataSource;
        // 数据库数据源认证
        private final MyUserDetalService myUserDetalService;
        // 自定义授权异常处理
        private final MyAccessDeniedHandler myAccessDeniedHandler;
    
        @Autowired
        public SecurityConfig(
                DataSource dataSource,
                StringRedisTemplate redisTemplate,
                MyUserDetalService myUserDetalService,
                MyAccessDeniedHandler myAccessDeniedHandler,
                MyLogoutSuccessHandler myLogoutSuccessHandler,
                MyAuthenticationEntryPoint myAuthenticationEntryPoint,
                MyAuthenticationFailureHandler myAuthenticationFailureHandler,
                MyAuthenticationSuccessHandler myAuthenticationSuccessHandler
                ) {
            this.redisTemplate = redisTemplate;
            this.myLogoutSuccessHandler = myLogoutSuccessHandler;
            this.myAuthenticationSuccessHandler = myAuthenticationSuccessHandler;
            this.myAuthenticationFailureHandler = myAuthenticationFailureHandler;
            this.myAuthenticationEntryPoint = myAuthenticationEntryPoint;
            this.dataSource = dataSource;
            this.myUserDetalService = myUserDetalService;
            this.myAccessDeniedHandler = myAccessDeniedHandler;
        }
    
        // 放行资源白名单
        private static final String[] WHITE = {
                "/login",
                "/css/**",
                "/img/**",
                "/captcha/**"
        };
    
        /**
         * TODO: 自定义前后端分离 Form 表单 => JSON 格式
         * 自定义 Filter 交给工厂管理
         */
        @Bean
        public LoginFilter loginFilter() throws Exception {
            LoginFilter loginFilter = new LoginFilter(redisTemplate);
            // 设置认证路径
            loginFilter.setFilterProcessesUrl("/login");
            // 指定接受 json 用户名的 key
            loginFilter.setUsernameParameter("username");
            // 指定接受 json 密码的 key
            loginFilter.setPasswordParameter("password");
            // 指定接受 json 验证码的 key
            loginFilter.setKaptchaParameter("kaptcha");
            // 指定接受 json 记住我的 key
            loginFilter.setRememberMeParameter("remember-me");
            // TODO 什么作用
            loginFilter.setAuthenticationManager(authenticationManagerBean());
            // 认账成功处理
            loginFilter.setAuthenticationSuccessHandler(myAuthenticationSuccessHandler);
            //认证失败处理
            loginFilter.setAuthenticationFailureHandler(myAuthenticationFailureHandler);
            // TODO 设置认证成功时使用自定义 rememberMeService
            loginFilter.setRememberMeServices(rememberMeServices());
            return loginFilter;
        }
    
        /**
         * authenticationManagerBean 是一个方法名,用于获取一个 Spring Security 的认证管理器实例,
         * 该方法将认证管理器实例化并将其注入到 Spring 上下文中以供其他 Bean 使用。
         * Spring Security 默认会为您提供一个认证管理器实例,但如果您需要在自己的代码中使用它,
         * 可以使用这个方法将其注入到您的代码中。
         * 在这个方法中,super.authenticationManagerBean() 调用了父类的同名方法,
         * 返回了一个 AuthenticationManager 实例。这个实例将被 Spring 管理并注入到上下文中。
         * Regenerate response
         *
         * @return
         * @throws Exception
         */
        @Override
        @Bean
        public AuthenticationManager authenticationManagerBean() throws Exception {
            return super.authenticationManagerBean();
        }
    
        /**
         * 自定义 AuthenticationManager 推荐
         * 它的作用是管理用户认证的过程。
         * 具体来说,它接收用户的登录请求并从Spring Security进行用户认证。在进行用户认证的过程中,AuthenticationManager 首先根据用户名获取用户信息,
         * 然后将给定的用户名和密码与用户信息进行比较,如果验证通过,则认为用户已经被认证。如果验证失败,则会抛出异常,表示用户认证失败。
         *
         * @param auth
         * @throws Exception
         */
        @Override
        protected void configure(AuthenticationManagerBuilder auth) throws Exception {
            auth.userDetailsService(myUserDetalService);
        }
    
        /**
         * 前后端分离的配置实现
         *
         * @param http
         * @throws Exception
         */
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                    // 前后端分离配置开启 csrf
                    .csrf()
                    // 将令牌保存到 cookie 中,允许 cookie 前端获取
                    .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                    .and()
                    // 放行资源
                    .authorizeRequests().mvcMatchers(WHITE).permitAll()
                    // 认证资源
                    .anyRequest().authenticated()
                    // 开启表单认证
                    .and()
                    .formLogin()
                    .and()
                    // 注销
                    .logout()
                    // 前后端分离的处理方式,页面不跳转,响应 json 格式
                    .logoutSuccessHandler(myLogoutSuccessHandler)
                    // 清除会话、清楚认证标记、注销成功后的默认跳转到登录页等为默认配置,可以不声明出现
                    // 退出的请求方式指定 GET、POST
                    .logoutRequestMatcher(new OrRequestMatcher(
                            new AntPathRequestMatcher("/logout", "GET"),
                            // 可以指定多种同时指定请求方式
                            new AntPathRequestMatcher("/myLogout", "POST")
                    ))
                    .and()
                    // 认证异常的处理
                    .exceptionHandling()
                    .authenticationEntryPoint(myAuthenticationEntryPoint)
                    // 授权异常处理
                    .accessDeniedHandler(myAccessDeniedHandler)
                    // 记住我
                    .and()
                    .rememberMe()
                    // 前后端分离的实现: 设置自动登录使用那个 rememberMe
                    .rememberMeServices(rememberMeServices())
                    // 跨域配置,当加入 SpringSecurity 后,原来SpringBoot的跨域解决失效
                    .and()
                    .cors()
            ;
            // at: 用来某个 filter 替换过滤器链中那个 filter
            // before: 放在过滤器链中那个 filter 之前
            // after: 放在过滤器链中那个 filter 之后
            http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
    
        }
    
        // 指定 RememberMe 数据持久化处理
        @Bean
        public PersistentTokenRepository persistentTokenRepository() {
            JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
            // 指定数据源
            tokenRepository.setDataSource(dataSource);
            // TODO 第一次使用需要设置为 true
            tokenRepository.setCreateTableOnStartup(false);
            return tokenRepository;
        }
    
        /**
         * 前后端分离记住我的实现
         *
         * @return MyRememberServices(String key, UserDetailsService userDetailsService, PersistentTokenRepository tokenRepository)
         */
        @Bean
        public RememberMeServices rememberMeServices() {
            return new MyRememberServices(UUID.randomUUID().toString(), userDetailsService(), persistentTokenRepository());
        }
    }
    

前后端分离相关自定义实现

  • 自定义授权异常处理

    @Component
    public class MyAccessDeniedHandler implements AccessDeniedHandler {
        @Override
        public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
            ResponseUtil.out(response, ResultModel.error(HttpStatus.FORBIDDEN.value(), "请获取授权后在访问...."));
        }
    }
    
    
  • 自定义认证异常处理

    @Component
    public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
        @Override
        public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
            ResponseUtil.out(response, ResultModel.error(HttpStatus.UNAUTHORIZED.value(), "请认证之后再去处理...."));
        }
    }
    
  • 自定义认证失败处理

    @Component
    public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
        @Override
        public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
            ResponseUtil.out(response, ResultModel.error(HttpStatus.UNAUTHORIZED.value(), "认证失败"));
        }
    }
    
  • 自定义认证成功后的处理

    @Component
    public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
        @Override
        public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
            ResponseUtil.out(response, ResultModel.success(HttpStatus.OK.value(), "认证成功", authentication));
        }
    }
    
  • 自定义注销成功的处理

    @Component
    public class MyLogoutSuccessHandler implements LogoutSuccessHandler {
        @Override
        public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
            ResponseUtil.out(response, ResultModel.success(HttpStatus.OK.value(), "注销成功"));
        }
    }
    
  • 自定义前后端分离认证 Filter

    package com.example.baizhisecurity.filter;
    
    import com.example.baizhisecurity.exception.KaptchaNotMatchException;
    import com.fasterxml.jackson.databind.ObjectMapper;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.data.redis.core.StringRedisTemplate;
    import org.springframework.http.MediaType;
    import org.springframework.security.authentication.AuthenticationServiceException;
    import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
    import org.springframework.security.core.Authentication;
    import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
    import org.springframework.util.ObjectUtils;
    
    import javax.servlet.ServletRequest;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.IOException;
    import java.util.Map;
    
    /**
     * 自定义前后端分离认证 Filter
     */
    @Slf4j
    public class LoginFilter extends UsernamePasswordAuthenticationFilter {
    
        private StringRedisTemplate redisTemplate;
    
        // 设置默认的表单验证码 name = kaptcha
        private static final String FORM_KAPTCHA_KEY = "kaptcha";
        private static final String FORM_REMEMBER_ME_KEY = "remember-me";
    
        private String kaptchaParameter = FORM_KAPTCHA_KEY;
        private String rememberMeParameter = FORM_REMEMBER_ME_KEY;
    
        public LoginFilter(StringRedisTemplate redisTemplate) {
            this.redisTemplate = redisTemplate;
        }
    
        // 提供自定义的验证码名称
        public String getKaptchaParameter() {
            return this.kaptchaParameter;
        }
    
        public void setKaptchaParameter(final String kaptchaParameter) {
            this.kaptchaParameter = kaptchaParameter;
        }
    
        public String getRememberMeParameter() {
            return rememberMeParameter;
        }
    
        public void setRememberMeParameter(String rememberMeParameter) {
            this.rememberMeParameter = rememberMeParameter;
        }
    
        @Override
        public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
            // 1. 判断请求方式是否是 POST
            if (!request.getMethod().equals("POST")) {
                throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
            }
            // 2. 判断 数据是否是 JSON 格式
            ServletRequest re = (ServletRequest) request;
            if (request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_VALUE)) {
                try {
                    // 将请求体中的数据进行反序列化
                    Map<String, String> userInfo = new ObjectMapper().readValue(request.getInputStream(), Map.class);
                    // 获取 json 用户名
                    String username = userInfo.get(getUsernameParameter());
                    // 获取 json 密码
                    String password = userInfo.get(getPasswordParameter());
                    // 获取 json 验证码
                    String kaptcha = userInfo.get(getKaptchaParameter());
                    // 获取 session 中的验证码
                    String redisCode = redisTemplate.opsForValue().get("kaptcha");
                    log.info("redisCode: {}", redisCode);
                    // 获取 json 中的记住我
                    String rememberMe = userInfo.get(getRememberMeParameter());
                    if (!ObjectUtils.isEmpty(rememberMe)) {
                        // 将这个 remember-me 设置到作用域中
                        request.setAttribute(getRememberMeParameter(), rememberMe);
                    }
                    // 用户输入的验证码和 session 作用域中的都不能为空
                    if (!ObjectUtils.isEmpty(kaptcha) && !ObjectUtils.isEmpty(redisCode) && kaptcha.equalsIgnoreCase(redisCode)) {
                        log.info("用户名: {} 密码: {},是否记住我: {}", userInfo, password, rememberMe);
                        // 获取用户名和密码认证
                        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
                        setDetails(request, authRequest);
                        return this.getAuthenticationManager().authenticate(authRequest);
                    }
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
                // 没有通过则执行自定义异常
                throw new KaptchaNotMatchException("验证码不匹配!");
            }
            // 如果不是 JSON 格式数据,则调用传统方式进行认证
            return super.attemptAuthentication(request, response);
        }
    }
    
    

记住我

  • 实现

    package com.example.baizhisecurity.config.rememberme;
    
    import org.springframework.core.log.LogMessage;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices;
    import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
    import org.springframework.util.ObjectUtils;
    
    import javax.servlet.http.HttpServletRequest;
    
    /**
     * TODO 这个类不能被 Spring 容器管理
     * 自定义记住我 service 的实现,这个类必须实现它的构造方法
     */
    public class MyRememberServices extends PersistentTokenBasedRememberMeServices {
        public MyRememberServices(String key, UserDetailsService userDetailsService, PersistentTokenRepository tokenRepository) {
            super(key, userDetailsService, tokenRepository);
        }
    
        /**
         * 自定义前后端分离获取 remember-me 的方式
         *
         * @param request
         * @param parameter
         * @return
         */
        @Override
        protected boolean rememberMeRequested(HttpServletRequest request, String parameter) {
            // 获取作用域中存储的 String rememberMe =
            Object parameterRememberMe = request.getAttribute(parameter);
            if (!ObjectUtils.isEmpty(parameterRememberMe)) {
                String rememberMe = parameterRememberMe.toString();
                if (rememberMe == null || !rememberMe.equalsIgnoreCase("true") && !rememberMe.equalsIgnoreCase("on") && !rememberMe.equalsIgnoreCase("yes") && !rememberMe.equals("1")) {
                    this.logger.debug(LogMessage.format("Did not send remember-me cookie (principal did not set parameter '%s')", parameter));
                    return false;
                } else {
                    return true;
                }
            }
            // 进行传统表单认证
            return super.rememberMeRequested(request, parameter);
        }
    }
    
    

跨域配置

  • 这个地方很特殊,在看到的教学过程中会在当前类下创建一个配置类,设置为数据源,但在这个项目学习的过程中出现了意外的错误CORS error,在这个过程中,预检请求发送成功,但是到了最真实的请求时,就出现错误,经过不断地修改跨域配置,前期在Vue项目中添加了devServer配置,对于跨域同样是失效的。

    // http 此种配置可能未生效在前后端分离中,但是之前使用的时候是生效的,这个点暂时属于疑问,希望多多评论
    http.cors().configurationSource(configurationSource())
    
    // SpringSecurity 配置后未能生效的跨域配置
    CorsConfigurationSource configurationSource() {
        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;
    }
    
  • 真实有效的解决方案

    package com.example.baizhisecurity.config;
    
    import org.springframework.context.annotation.Configuration;
    import org.springframework.web.servlet.config.annotation.CorsRegistry;
    import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
    
    /**
     * 1. 先对 SpringBoot 配置,运行跨域请求
     */
    @Configuration
    public class CorsConfig implements WebMvcConfigurer {
        @Override
        public void addCorsMappings(CorsRegistry registry) {
            // 设置允许跨域的路径
            registry.addMapping("/**")
                    // 设置允许跨域请求的域名
                    .allowedOriginPatterns("*")
                    // 是否允许 Cookie
                    .allowCredentials(true)
                    // 设置允许的请求方式
                    .allowedMethods("GET", "POST", "DELETE", "PUT")
                    // 设置允许的 header 属性
                    .allowedHeaders("*")
                    // 设置允许时间
                    .maxAge(3600L);
        }
    }
    
    // 2. 最后只需要在 SpringSecurity 的 hppt 配置跨域
    http.cors();
    

    在经过上面两步后,成功解决CORS引起的问题并成功的获取到了数据。

验证码

  • 配置验证码

    @Configuration
    public class KaptchaConfig {
        @Bean
        public Producer kaptcha() {
            Properties properties = new Properties();
            properties.setProperty("kaptcha.image.width", "120");
            properties.setProperty("kaptcha.image.height", "40");
            properties.setProperty("kaptcha.textproducer.char.string", "0123456789qwertyuiopasdfghjklzxcvbnmQWERTYUIOASDFGHJKLZXCVBNM");
            properties.setProperty("kaptcha.textproducer.char.length", "4");
            Config config = new Config(properties);
            DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
            defaultKaptcha.setConfig(config);
            return defaultKaptcha;
        }
    }
    
  • 验证码的控制器类

    @Slf4j
    @CrossOrigin
    @RestController
    public class CaptchaController {
        @Autowired
        private Producer producer;
        @Autowired
        private StringRedisTemplate redisTemplate;
    
        @GetMapping("/captcha")
        public ResultModel getVerifyCode(HttpServletRequest request, HttpServletResponse response, HttpSession session) throws IOException {
            // 1. 生成验证码
            String text = producer.createText();
            log.info("code text: {}", text);
            // 2. TODO 放入 session/redis
            redisTemplate.opsForValue().set("kaptcha", text);
            // 3. 生成图片
            BufferedImage image = producer.createImage(text);
            FastByteArrayOutputStream fos = new FastByteArrayOutputStream();
            ImageIO.write(image, "jpg", fos);
            String base64Img = Base64.encodeBase64String(fos.toByteArray());
            return ResultModel.success(HttpStatus.OK.value(), "验证码获取成功!", base64Img);
        }
    }
    

自定义全局异常

  • 验证码异常

    public class KaptchaNotMatchException extends AuthenticationException {
        public KaptchaNotMatchException(String msg, Throwable cause) {
            super(msg, cause);
        }
        public KaptchaNotMatchException(String msg) {
            super(msg);
        }
    }
    
  • 全局异常处理

    @ControllerAdvice
    public class GlobalExceptionHandle {
        @ResponseBody
        @ExceptionHandler(Exception.class)
        public ResultModel error(Exception e) {
            e.printStackTrace();
            return ResultModel.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "执行了全局异常处理!");
        }
    }
    

Mapper 定义

  • Mapper定义

    @Repository
    public interface UserMapper {
        User findUserByUserName(String username);
    
        List<Role> getRoleByUid(Integer uid);
    
        Integer updatePassword(String username, @Param("password") String newPassword);
    }
    
    
  • Mapper映射实现

    <?xml version="1.0" encoding="UTF-8" ?>
    <!DOCTYPE mapper
            PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
            "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
    <mapper namespace="com.example.baizhisecurity.mapper.UserMapper">
        <!-- User findUserByUserName(String username); -->
        <select id="findUserByUserName" resultType="user">
            select *
            from user
            where username = #{username}
        </select>
    
        <!-- List<Role> getRoleByUid(Integer uid); -->
        <select id="getRoleByUid" resultType="role">
            select r.id, r.name, r.name_zh
            from role r,
                 user_role ur
            where r.id = ur.uid
              and ur.uid = #{uid}
        </select>
    
    
        <!--  Integer updatePassword(@Param("username") String username,@Param("password") String password);-->
        <update id="updatePassword">
            update `user`
            set password = #{password}
            where username = #{username}
        </update>
    </mapper>
    
    

业务类实现

  • UserDetailsService

    package com.example.baizhisecurity.service;
    
    import com.example.baizhisecurity.entity.Role;
    import com.example.baizhisecurity.entity.User;
    import com.example.baizhisecurity.mapper.UserMapper;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.security.core.userdetails.UserDetails;
    import org.springframework.security.core.userdetails.UserDetailsPasswordService;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.security.core.userdetails.UsernameNotFoundException;
    import org.springframework.stereotype.Service;
    import org.springframework.util.ObjectUtils;
    
    import java.util.List;
    
    @Service
    public class MyUserDetalService implements UserDetailsService, UserDetailsPasswordService {
        @Autowired
        private UserMapper userMapper;
    
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            // 1. 查询用户
            User user = userMapper.findUserByUserName(username);
            if (ObjectUtils.isEmpty(user)) {
                throw new UsernameNotFoundException("用户不存在");
            }
            // 2. 查询用户的权限信息
            // 查询权限信息
            List<Role> roles = userMapper.getRoleByUid(user.getId());
            user.setRoles(roles);
            return user;
        }
    
        /**
         * 自动密码升级解决方案 {推荐: 随着 SpringSecurity 版本的升级,密码的底层加密会实现自动升级}
         *
         * @param user
         * @param newPassword
         * @return
         */
        // 实现密码更新
        @Override
        public UserDetails updatePassword(UserDetails user, String newPassword) {
            Integer updatePassword = userMapper.updatePassword(user.getUsername(), newPassword);
            if (updatePassword == 1) {
                ((User) user).setPassword(newPassword);
            }
            return user;
        }
    }
    
    

前端部分

  • ElemenUI

    选择了全局安装

  • 登录表单

    <template>
      <div class="login" v-cloak>
        <div class="left">
          <video autoplay="autoplay" loop="loop" muted oncanplay="true" src="@/assets/video/passport.mp4"></video>
        </div>
        <div class="right">
          <div class="box">
            <p>
              <strong> 登录 </strong>
              <span>没有账户? <router-link to="/register">免费注册</router-link>
              </span>
            </p>
            <el-form :model="ruleForm" :rules="rules" ref="ruleForm" label-width="100px" class="demo-ruleForm">
              <el-form-item label="" prop="username">
                <el-input placeholder="请输入账号" v-model="ruleForm.username" type="text">
                  <i slot="suffix" class="el-input__icon icon-jurassic_user"></i>
                </el-input>
              </el-form-item>
              <el-form-item label="" prop="password">
                <el-input v-model="ruleForm.password" ref="pwdRef" placeholder="请输入密码" :type="inputType">
                  <i slot="suffix" class="el-input__icon icon-mima" @click="showPasswd"></i>
                </el-input>
              </el-form-item>
              <el-form-item label="" prop="kaptcha" class="code">
                <el-input placeholder="请输入验证码" v-model="ruleForm.kaptcha" type="text" style="width: 170px;">
                  <i slot="suffix" class="el-input__icon icon-yanzhengma"></i>
                </el-input>
                <img :src="kaptchaCode" ref="captchaImg" alt="" title="点击刷新" @click="refreshCaptcha">
              </el-form-item>
              <el-button @click="loginHandle">登录</el-button>
            </el-form>
          </div>
        </div>
      </div>
    </template>
    
    <script>
    import { loginNetwork, refNewCode } from "@/network/user/user";
    export default {
      data() {
        return {
          ruleForm: {
            username: '', // 用户名
            password: '', // 密码
            kaptcha: '' // 验证码
          },
          kaptchaCode: "",
          showPassword: false, // 默认不显示密码
          rules: {
            username: [
              { required: true, message: '请输入用户名', trigger: 'blur' },
              {
                min: 3,
                max: 15,
                message: '长度在 3 到 15 个字符',
                trigger: 'blur',
              },
            ],
            password: [
              { required: true, message: '请输入密码', trigger: 'blur' },
              {
                min: 3,
                max: 15,
                message: '长度在 3 到 15 个字符',
                trigger: 'blur',
              },
            ],
            kaptcha: [
              { required: true, message: '请输入验证码', trigger: 'blur' },
              {
                min: 3,
                max: 5,
                message: '长度在 4 个字符',
                trigger: 'blur',
              },
            ],
          },
        }
      },
      computed: {
        // 修改密码显示
        inputType() {
          return this.showPassword ? 'text' : 'password';
        },
      },
      created() {
        this.refreshCaptcha()
      },
      methods: {
        // 点击刷新验证码
        refreshCaptcha() {
          refNewCode().then(res => {
            if (res.code === 200) {
              // 解析 base64 图片资源 data:image/png;base64,
              this.kaptchaCode = "data:image/png;base64," + res.data
              this.$message.success(res.message || "刷新成功!")
            } else {
              this.$message.error(res.message || "验证码获取失败!")
            }
          })
    
        },
        // 点击显示验证码明文字符
        showPasswd() {
          this.showPassword = !this.showPassword;
        },
        // 点击登录事件
        loginHandle() {
          // 表单校验
          this.$refs.ruleForm.validate((valid) => {
            if (valid) {
              console.log(valid)
              loginNetwork(this.ruleForm).then(res => {
                console.log("loginNetwork: ", res)
                // 判断 code
                if (res.code === 200) {
                  this.$message.success(res.message)
                  // TODO 页面跳转
                  this.$router.push("/admin")
                } else {
                  this.$message.error(res.message)
                }
              })
            }
          })
        }
      },
    }
    </script>
    
    <style lang="less" scoped>
    [v-cloak] {
      display: none;
    }
    
    .code {
      display: flex;
      justify-content: space-between;
      align-items: center;
    
      img {
        height: 40px;
        line-height: 40px;
        margin-left: 10px;
        vertical-align: middle;
      }
    }
    
    .icon-yanjing_xianshi {
      position: absolute;
      font-size: 14px;
      z-index: 1;
      right: 10px;
      color: #606266;
      font-family: iconfont;
    }
    
    .el-button:hover {
      background: #ffa459;
    }
    
    .icon-mima,
    .icon-yanzhengma,
    .icon-jurassic_user {
      font-family: iconfont;
    }
    
    .box p {
      position: relative;
      left: 80px;
      padding: 20px;
    
      strong {
        font-size: 32px;
        font-weight: 600;
        line-height: 40px;
        color: #121315;
      }
    
      span {
        display: block;
        margin-top: 8px;
        font-size: 14px;
        font-weight: 400;
        line-height: 22px;
        color: #767e89;
      }
    
      a {
        color: #fb9337;
        cursor: pointer;
        transition: color 0.3s;
      }
    }
    
    .right {
      position: relative;
      width: 50%;
      margin-left: 140px;
      box-sizing: border-box;
    
      .box {
        position: absolute;
        top: 300px;
      }
    
      .el-form {
        width: 100%;
    
        .el-input {
          width: 300px;
        }
      }
    }
    
    .el-button {
      position: relative;
      left: 100px;
      width: 300px;
      color: #fff;
      background-color: #fb9337;
    }
    
    .login {
      display: flex;
      justify-content: space-between;
      width: 100%;
      height: 100%;
    
      .left video {
        display: inline-block;
        width: 100%;
        height: 100vh;
        object-fit: cover;
      }
    }
    </style>
    
    • 渲染效果

      登录页面
      ilMcJk.png
  • 发送请求认证测试

    表单测试
    il1WVN.gif

代码下载

  • 源代码下载

    https://gitee.com/coderitl/split-springsecurity.git

特殊点说明

  • 项目整体采用的是前后端分离开发
  • 前后端分离后的特点是所有响应以JSON格式显示
  • 在登录页面上,需要特别的注意自定义登录页面是针对传统的WEB开发,而前后端分离是将登陆表单以JSON格式显示的

项目测试

  • 测试获取验证码

    http://localhost:8080/captcha

    data是图片数据的Base64显示,前端是需要拼接的POSTMAN测试
  • 测试直接访问控制器数据

    未登陆时访问数据
    • 细节

      1. 这里需要注意,使用的时候需要在header中添加CSRF需要的键值

        第一步获取cookie中关于CSRF相关的键值
      2. 将上图中红色框中的值复制下来,添加到本次请求的header

        CSRF配置
      3. 在添加好后,再次访问请求

        成功获得认证
      4. 下次访问时,需要删除headerCSRF的值,之后再次添加

      5. 疑问点难道每一次都需要访问一次失败的再添加cookie后才能访问成功吗?

        在前端使用的时候,是通过添加相关的配置获取的是cookie的内容,所以访问时就已经实现了添加,所以不会出现访问失败一次的现象。

      6. VueCSRF的配置

        • 下载插件

          # 下载 cookie 使用的插件
          npm install vue-cookie --save
          
        • 使用

          // config.js
          import axios from "axios";
          import VueCookie from "vue-cookie";
          
          axios.defaults.xsrfHeaderName = "X-CSRF-TOKEN";
          axios.defaults.xsrfCookieName = "CSRF-TOKEN";
          axios.defaults.withCredentials = "true";
          
          export function request(config) {
            // 1.创建axios的实例
            const instance = axios.create({
              baseURL: "http://localhost:8080",
              timeout: 5000,
            });
          
            // 2.axios的拦截器
            // 2.1.请求拦截的作用
            instance.interceptors.request.use(
              (config) => {
                // 在发送请求之前做些什么
                // 获取 CSRF Token
                const csrfToken = VueCookie.get("XSRF-TOKEN");
                console.log("csrfToken: " + csrfToken);
                if (csrfToken) {
                  // 在请求头中添加 CSRF Token
                  config.headers["X-XSRF-TOKEN"] = csrfToken;
                }
                return config;
              },
              (err) => {
                // 对请求错误做些什么
                return Promise.reject(err);
              }
            );
          
            // 2.2.响应拦截
            instance.interceptors.response.use(
              (res) => {
                return res.data;
              },
              (err) => {
                console.log(err);
              }
            );
          
            // 3.发送真正的网络请求
            return instance(config);
          }
          
          

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/419063.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

电容笔和Apple pencil的区别是什么?好用电容笔推荐

Apple Pencil与目前市场上常见的电容笔最大的不同之处在于&#xff0c;普通电容笔并不具备苹果Pencil特有的重力压感&#xff0c;而仅仅是一种倾斜的压感。不过&#xff0c;其在其它方面的表现也很出色&#xff0c;与Apple Pencil相似&#xff0c;而且价格仅为200元。现在&…

项目管理中的冲突是什么?

项目管理中的冲突可以采取多种不同的形式。团队成员在创意愿景上存在分歧&#xff0c;与高层管理人员就期望和时间表发生争执&#xff0c;甚至与第三方供应商发生争执&#xff0c;都是项目冲突的主要例子。 冲突的常见原因是什么&#xff1f; 基于项目的组织内部冲突的典型原因…

【记录】Git连接gitee、新建仓库

学习记录1.连接gitee2.新建仓库1.连接gitee https://www.cnblogs.com/cokefentas/p/14727592.html git安装与卸载 apt-get install git apt-get remove gitgit配置 配置用户名 git config --global user.name "your name" 配置邮箱 git config --global user.email…

2023都说测试行业饱和了,为什么我们公司新招的的测试开了15K?

其实每年都有人说测试行业饱和了&#xff0c;但依旧有很多人找到了薪资不错的工作。来说说我的看法吧&#xff0c;我认为不用担心测试会饱和的问题&#xff0c;我们人口基数大&#xff0c;任何一个行业都有竞争&#xff0c;这是非常正常的情况。而且在有技术能力的人面前永远没…

Vue3通透教程【十一】初探TypeScript

文章目录&#x1f31f; 写在前面&#x1f31f; TypeScript是什么&#xff1f;&#x1f31f;TypeScript 增加了什么&#xff1f;&#x1f31f;TypeScript 初体验&#x1f31f; 写在最后&#x1f31f; 写在前面 专栏介绍&#xff1a; 凉哥作为 Vue 的忠实 粉丝输出过大量的 Vue …

什么是进程,线程,协程

一.进程1.简介计算机的核心是CPU&#xff0c;它承担了所有的计算任务&#xff1b;而操作系统是计算机的管理者&#xff0c;它负责任务的调度、资源的分配和管理&#xff0c;统领整个计算机硬件&#xff1b;应用程序则是具有某种功能的程序&#xff0c;程序是运行于操作系统之上…

十分钟验证一个轻量化车联网解决方案

智能网联汽车在车联网的应用上&#xff0c;通常是以智能传感器、物联网、GIS技术为基础&#xff0c;结合大数据、人工智能技术&#xff0c;通过OT&#xff08;Operation tecnology&#xff09;和IT&#xff08;information tecnology&#xff09;融合的方式&#xff0c;实现智能…

使用Ubuntu22.04搭建k8s环境和一些k8s基础知识

minikube搭建 基本环境 我使用virtualBox构建的ubuntu&#xff0c;选择4核4G内存minikube是一个K8S集群模拟器&#xff0c;可以快速构建一个单节点的集群&#xff0c;用于在本地测试和开发首先使用官方脚本安装docker curl -fsSL https://test.docker.com -o test-docker.sh…

nacos源码服务注册

nacos服务注册序言1.源码环境搭建1.1idea运行源码1.2 登录nacos2.服务注册分析2.1 客户端2.1.1容器启动监听2.1.2注册前初始化2.1.3注册服务2.2 服务端2.2.1注册2.2.2重试机制3.注意事项序言 本文章是分析的是nacos版本2.2 这次版本是一次重大升级优化&#xff0c;由原来&#…

【MySQL | 基础篇】02、MySQL 函数详解

目录 一、字符串函数 1.1 concat : 字符串拼接 1.2 lower : 全部转小写 1.3 upper : 全部转大写 1.4 lpad : 左填充 1.5 rpad : 右填充 1.6 trim : 去除空格 1.7 substring : 截取子字符串 1.8 案例 二、数值函数 2.1 ceil&#xff1a;向上取整 2.2 floor&#xff…

【Java版oj】day34收件人列表、养兔子

目录 一、收件人列表 &#xff08;1&#xff09;原题再现 &#xff08;2&#xff09;问题分析 &#xff08;3&#xff09;完整代码 二、养兔子 &#xff08;1&#xff09;原题再现 &#xff08;2&#xff09;问题分析 &#xff08;3&#xff09;完整代码 一、收件人列表 …

Python机器学习:支持向量机2

昨天是简单的了解了一下支持向量机要干什么以及线性可分支持向量机是怎么一回事&#xff0c;今年来看另一种&#xff1a;线性支持向量机&#xff1a; 我们昨天说的&#xff0c;线性可分支持向量机的目的就是找到一个超平面来吧一个数据集分成正负两个部分&#xff0c;但是实际…

一天学完C++的标准模板库STL

标准模板库STLstring字符串如何对string字符串的初始化&#xff08;声明&#xff09;&#xff1f;如何遍历string对象进行访问&#xff1f;如何对string类型的字符串进行增删改查&#xff1f;对string字符串增加一些字符对string字符串删除一些字符对string字符串改动一些字符在…

【hello Linux】环境变量

目录 1. 环境变量的概念 2. 常见的环境变量 3. 查看环境变量 4. 和环境变量相关的命令 5. 环境变量的组织方式 6. 通过代码获取环境变量 7. 通过系统调用获取环境变量 Linux&#x1f337; 在开始今天的内容之前&#xff0c;先来看一幅图片吧&#xff01; 不知道你们是否和我一…

数据结构的加强甜点-序列1

目录 尾递归 问题 介绍 特点 原理 答案 数组栈堆内存分配 前言 分析 再分析 所谓多维数组 程序局部性原理应用 尾递归 问题 在空间复杂度这块&#xff0c;有个O(n)示例如下&#xff1a; void recur(int n) {if (n 1) return;return recur(n - 1); } 这很明显是…

Canal(1):Canal入门

1 什么是 Canal 阿里巴巴 B2B 公司&#xff0c;因为业务的特性&#xff0c;卖家主要集中在国内&#xff0c;买家主要集中在国外&#xff0c;所以衍生出了同步杭州和美国异地机房的需求&#xff0c;从 2010 年开始&#xff0c;阿里系公司开始逐步的尝试基于数据库的日志解析&am…

SpringSecurity之微服务权限解决方案

目录 前置知识点 什么是微服务 微服务的优劣 优点 缺点 微服务本质 微服务认证与授权实现思路 认证预授权的过程 前置知识点 什么是微服务 微服务&#xff08;或称微服务架构&#xff09;是一种云 原 生 架构方法&#xff0c;在单个应用中包含众多松散耦合而且可单独部…

君子生非异也,善假于物也。【借助外力获取能量,主动改善生存环境。】

文章目录 引言I 借助外力1.1 制造很实用工具1.2 火引言 人类懂得借助外力,从被动地适应环境的进化,进入到主动改善生存环境的发展轨道上了。 人之所以为人,是因为我们善于借助外力,而不是先天有多少优势。 I 借助外力 学会制造和使用工具,提高能量获取的效率学会使用火,…

苹果手写笔有必要买吗?性价比电容笔排行榜

众所周知&#xff0c;苹果的正版Pencil的售价过于的高&#xff0c;一般的用户是买不起的。那么&#xff0c;市场上是否会有一款苹果Pencil的平替电容笔&#xff0c;而这两款电容笔在功能上是完全相同的&#xff1f;的确如此。国内的平替电容笔在书写方面上跟苹果Pencil差别不大…

【多线程的应用】顺序打印

【多线程的应用】顺序打印题目注意点&#xff1a;1. 每个线程循环10次&#xff0c;利用锁的wait 和 计数器count调节线程的执行顺序2. count后 lock.notifyAll 唤醒所有线程3. Thread.currentThread().getName()4. 锁中的逻辑是&#xff1a;进入锁中后&#xff0c;如果while不满…