前后端分离下的-SpringSecurity

前后端分离下的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.mfbz.cn/a/6844.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

商务谈判Business Negotiation

目录 前言原文文章商务谈判常用会话前言 继续💪 原文文章 商务谈判常用会话 ❶ I cannot understand your point well. 我不太理解你的观点。 ❷ I’m conferring with my customers about online orders. 我现在跟我的顾客协商网上订单的事。 ❸ We express our pleasur…

Generalist: Decoupling Natural and Robust Generalization

通过原始图片在训练过程出的模型会受到敌对样本的干扰&#xff0c;这种问题虽然通过对抗训练增加了抵抗敌对样本的鲁棒性&#xff0c;但也损失了一部分自然泛化的能力。为了解决这个问题&#xff0c;我们将自然泛化和鲁棒泛化与联合训练解耦&#xff0c;并为每个训练制定不同的…

如何有效地跟踪项目进展?

项目失败的代价很高。通过进度跟踪&#xff0c;你可以预见问题&#xff0c;并采取必要的措施引导项目回到正轨。 根据最近的一项研究&#xff0c;由于项目表现不佳&#xff0c;组织平均浪费了其总投资的11.4%。此外&#xff0c;在那些低估了健全项目管理的重要性的企业中&…

高频面试:如何解决MySQL主从复制延时问题

MySQL 主从一直是面试常客&#xff0c;里面的知识点虽然基础&#xff0c;但是能回答全的同学不多。 比如我之前面试小米&#xff0c;就被问到过主从复制的原理&#xff0c;以及主从延迟的解决方案&#xff0c;你之前面试&#xff0c;有遇到过哪些 MySQL 主从的问题呢&#xff…

Goby漏洞更新 | SolarView Compact downloader.php 任意命令执行漏洞(CVE-2023-23333)

漏洞名称&#xff1a;SolarView Compact downloader.php 任意命令执行漏洞&#xff08;CVE-2023-23333 English Name&#xff1a;SolarView Compact downloader.php RCE (CVE-2023-23333) CVSS core: 10.0 影响资产数&#xff1a;5585 漏洞描述&#xff1a; Contec SolarV…

Java题目训练——统计每个月兔子的总数和字符串通配符

目录 一、统计每个月兔子的总数 二、字符串通配符 一、统计每个月兔子的总数 题目描述&#xff1a; 有一种兔子&#xff0c;从出生后第3个月起每个月都生一只兔子&#xff0c;小兔子长到第三个月后每个月又生一只兔子。 例子&#xff1a;假设一只兔子第3个月出生&#xff0c…

天气Weather

前言 加油 原文 天气常用会话 ❶ It looks as though it might clear up. 看起来天好像要转晴。 ❷ The forecast is not accurate. 预报不准确。 ❸ The weatherman says we’ll have a cold spell before the end of this week. 天气预报员说,在这个周末之前会有一股寒…

【数据结构与算法分析】0基础带你学数据结构与算法分析12--红黑树

红黑树 (red-black tree) 是一种自平衡二叉树&#xff0c;于 1972 年由 Rudolf Bayer 发明&#xff0c;发明时被称为 对称二叉 B 树&#xff0c;现代名称红黑树来自 Knuth 的博士生 Robert Sedgewick 于 1978 年发表的论文。红黑树的结构复杂&#xff0c;但操作有着良好的最坏情…

新的勒索软件是迄今为止最快的加密器

在一家美国公司遭到网络攻击后&#xff0c;恶意软件研究人员发现了一种似乎具有“技术独特功能”的新型勒索软件&#xff0c;他们将其命名为 Rorschach。 观察到的功能之一是加密速度&#xff0c;根据研究人员的测试&#xff0c;这将使 Rorschach 成为当今最快的勒索软件威胁。…

对Mysql的了解-索引

什么是索引? 索引是一种用于快速查询和检索数据的数据结构。常见的索引结构有: B 树&#xff0c; B树和 Hash。 索引的作用就相当于目录的作用。打个比方: 我们在查字典的时候&#xff0c;如果没有目录&#xff0c;那我们就只能一页一页的去找我们需要查的那个字&#xff0c…

结合基于规则和机器学习的方法构建强大的混合系统

经过这些年的发展&#xff0c;我们都确信ML即使不能表现得更好&#xff0c;至少也可以在几乎所有地方与前ML时代的解决方案相匹配。比如说一些规则约束&#xff0c;我们都会想到能否把它们替换为基于树的ml模型。但是世界并不总是黑白分明的&#xff0c;虽然机器学习在解决问题…

nacos本地启动单节点

1.官网下载 Releases alibaba/nacos GitHub 解压文件 unzip nacos-server-2.2.1.zip cd /Users/xiaosa/dev_tools/nacos/bin sh startup.sh -m standalone 启动不成功&#xff0c;报错入如下 原因是下面的配置为空。位置在nacos/config目录下的application.properties文件…

【英语】大学英语CET考试,导学规划与听力题答题技巧笔记(1-2)

文章目录1、课程规划和导学1.1 试卷结构和备考目标1.2 单词&#xff0c;听力&#xff0c;阅读&#xff0c;真题学习方法2、听力技巧课1&#xff08;导学与发音&#xff09;3、听力技巧课2&#xff08;答题技巧&#xff01;重要&#xff01;&#xff09;1、课程规划和导学 主讲…

C语言中宏和函数的9个区别,你都了解吗?

C语言中的宏和函数是非常相似的&#xff0c;它们都可以完成类似的功能。比如&#xff0c;想要求2个数的较大值&#xff0c;使用宏的写法是&#xff1a; // 宏的定义 #define MAX(x, y) ((x)>(y)?(x):(y))// 使用 int m MAX(10, 20);使用函数的写法是&#xff1a; // 函数…

[Golang从零到壹] 1.环境搭建和第三方包管理

文章目录安装go环境go.mod第一种情况&#xff0c;选择GOPATH第二种情况&#xff0c;不选择GOPATH(推荐)GO111MODULEgo module可执行文件位置安装go环境 go在安装时选择好安装目录完成安装之后&#xff0c;还需要设置两个环境变量&#xff1a;GOROOT、GOPATH GOROOT即go的安装…

UnQLite入门

本文介绍UnQLite的基本使用&#xff0c;包括增删改查&#xff0c;事务ACID 文章目录UnQLite介绍UnQLite常用接口函数返回码DemoKey/Value存储数据库游标UnQLite介绍 UnQLite简介 UnQLite是&#xff0c;由 Symisc Systems公司出品的一个嵌入式C语言软件库&#xff0c;它实现了一…

Scrapy-核心架构

在之前的文章中&#xff0c;我们已经学习了如何使用Scrapy框架来编写爬虫项目&#xff0c;那么具体Scrapy框架中底层是如何架构的呢&#xff1f;Scrapy主要拥有哪些组件&#xff0c;爬虫具体的实现过程又是怎么样的呢&#xff1f; 为了更深入的了解Scrapy的相关只是&#xff0…

Chatgpt 指令收集

在使用 ChatGPT 时&#xff0c;当你给的指令越精确&#xff0c;它的回答会越到位&#xff0c;举例来说&#xff0c;假如你要请它帮忙写文案&#xff0c;如果没给予指定情境与对象&#xff0c;它会不知道该如何回答的更加准确。 一、写报告 1、我现在正在 [报告的情境与目的]。…

低代码平台应该具备哪些能力?

什么样的低代码无代码平台才算好的平台呢&#xff0c;Gartner 共列出了低代码平台的11个关键能力维度&#xff1a; 1、易用性。易用性是标识低代码平台生产力的关键指标&#xff0c;是指在不写代码的情况下能够完成的功能的多少。 2、用户体验。一般来说&#xff0c;独立软件开…

2023Q2押题,华为OD机试用Python实现 -【机智的外卖员】

最近更新的博客 华为 od 2023 | 什么是华为 od,od 薪资待遇,od 机试题清单华为 OD 机试真题大全,用 Python 解华为机试题 | 机试宝典【华为 OD 机试】全流程解析+经验分享,题型分享,防作弊指南华为 od 机试,独家整理 已参加机试人员的实战技巧本篇题解:机智的外卖员 题目…
最新文章