首页 > 其他分享 >SpringSecurity + Spnego + Kerberos 实现AD域单点登录

SpringSecurity + Spnego + Kerberos 实现AD域单点登录

时间:2024-10-16 21:51:11浏览次数:3  
标签:AD 登录 Kerberos 用户 SpringSecurity new true public

SpringSecurity + Spnego + Kerberos 实现AD域单点登录

文章目录


前言

本文主要介绍了域的基本概念,Kerberos 的基本概念。以及如何结合实现域单点登录及域用户的同步管理。


一、域是什么?

域,英文domain,是计算机专用词汇,是指Windows网络中独立运行的单位。
实际上我们可以把域和工作组联系起来理解,在工作组上你一切的设置在本机上进行包括各种策略,用户登录也是登录在本机的,密码是放在本机的数据库来验证的。而如果你的计算机加入域的话,各种策略是域控制器统一设定,用户名和密码也是放到域控制器去验证,也就是说你的账号密码可以在同一域的任何一台计算机登录。
常见于大型企业的计算机安全管理。因此部分软件开发时要求接入域管理。用户在登录自己电脑后,可直接打开对应系统,无需输入软件的账号密码可直接登录。

如何安装部署自己的域可参考:Windows Server 2016域服务器部署

二、单点登录是什么?

单点登录(Single Sign On),简称为 SSO,是比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。

在本场景下,用户打开电脑输入域账户登录到桌面,即可直接访问互信的系统,无需更多的操作。

三、如何实现

Spring Security 提供了Kerberos 认证的扩展。可通过Kerberos 协议进行域服务与系统之间的互信,从而实现单点登录。

注:本案例基于Spring Security 5.1.5.RELEASE 和 JDK 8 版本编写,故参考文档为 spring-security-kerberos的1.0.1.RELEASE版本

当前最新的 Spring Security Kerberos 2.1.1 需要基于 JDK 17、 Spring Security 6.3.0 和 Spring Framework 6.1.8。可参考官方最新文档

四、Kerberos

Kerberos是一种计算机网络授权协议,用来在非安全网络中,对个人通信以安全的手段进行身份认证。这个词又指麻省理工学院为这个协议开发的一套计算机软件。

对于Kerberos 的概念介绍已经有很多,这里不再赘述,推荐观看下面几位大佬的几篇博客

Windows安全认证机制——Kerberos 域认证
kerberos认证原理—讲的非常细致,易懂
Kerberos原理–经典对话

五、Spnego 介绍

SPNEGO(Simple and Protected GSS-API Negotiation)是一种由微软提供的安全协议‌,它使用GSS-API(通用安全服务应用接口)认证机制,旨在使Web服务器能够共享Windows凭据,并扩展了Kerberos协议,这是一种网络认证协议‌。

SPNEGO协议的主要功能是在客户端和Web服务器之间协商一个通信协议,并定义了一种方式,通过这种方式,客户端可以发送可验证的证明数据给Web服务器。如果Web服务器能够解析客户端发送的SPNEGO令牌,那么这个令牌将用于实现安全单点登录,从而以安全的方式协商用户的身份‌。

SPNEGO协议的工作流程大致如下:客户端根据用户名向密钥分发中心(KDC)的身份认证服务(AS)请求票据授予服务(TGS)票证。AS生成TGS票证,查询用户的密码,并用用户的密码将TGS票证加密后返回给客户端。客户端使用用户的密码解密TGS票证,如果密码正确,就能获取TGS票证,然后使用该票证向TGS服务请求服务票证。TGS服务生成服务票证并响应给客户端。客户端将服务票证封装到SPNEGO令牌中,并发送给Web服务器。服务器解密出用户名及服务票证,将票证发送到TGS服务进行验证。如果验证通过,通信开始‌。

SPNEGO协议支持基于Kerberos或NTLM凭据的Active Directory凭据。不同的实现方案可能支持不同的认证方式,例如IBM TAI方案只支持基于Kerberos凭据的认证,而Tivoli Access Manager支持NTLM和Kerberos两种凭据‌。

六、spring-ldap 连接域实现域用户管理

通过 spring-security-ldap 提供的类库可以轻松实现与域服务器用户的连接与同步。

  1. 添加配置信息
spring.ldap.urls=ldap://testldap.com:389
spring.ldap.base=CN=Users,DC=testldap,DC=com
spring.ldap.username=CN=Administrator,CN=Users,DC=testldap,DC=com
spring.ldap.password=test
  1. 编写自动配置类
/**
 1. LDAP 的自动配置类
 2. 该类用于配置和初始化LDAP连接,以便在应用程序中使用LDAP服务。
 */
@Configuration
public class LdapConfiguration {

    // 日志记录器
    private static Logger logger = LoggerFactory.getLogger(LdapConfiguration.class);

    // LDAP 模板对象,用于执行LDAP操作
    private LdapTemplate ldapTemplate;

    // 从配置文件中读取LDAP服务器的URL
    @Value("${spring.ldap.urls}")
    private String dbUrl;

    // 从配置文件中读取LDAP服务器的用户名
    @Value("${spring.ldap.username}")
    private String username;

    // 从配置文件中读取LDAP服务器的密码
    @Value("${spring.ldap.password}")
    private String password;

    // 从配置文件中读取LDAP服务器的基础DN
    @Value("${spring.ldap.base}")
    private String base;

    /**
     * 创建并配置LdapContextSource对象
     * 该对象用于建立与LDAP服务器的连接
     * 
     * @return 配置好的LdapContextSource对象
     */
    @Bean
    public LdapContextSource contextSource() {
        LdapContextSource contextSource = new LdapContextSource();
        Map<String, Object> config = new HashMap<>();
        contextSource.setUrl(dbUrl);
        contextSource.setBase(base);
        contextSource.setUserDn(username);
        contextSource.setPassword(password);
        // 解决乱码的关键配置
        config.put("java.naming.ldap.attributes.binary", "objectGUID");
        contextSource.setPooled(true);
        contextSource.setBaseEnvironmentProperties(config);
        return contextSource;
    }

    /**
     * 创建并配置LdapTemplate对象
     * 该对象用于执行LDAP操作
     * 
     * @param contextSource 已配置的LdapContextSource对象
     * @return 配置好的LdapTemplate对象
     */
    @Bean
    public LdapTemplate ldapTemplate(LdapContextSource contextSource) {
        if (Objects.isNull(contextSource)) {
            throw new RuntimeException("ldap contextSource error");
        }
        if (null == ldapTemplate) {
            ldapTemplate = new LdapTemplate(contextSource);
        }
        return ldapTemplate;
    }
}
  1. 编写定时任务定时同步用户信息
public void syncUserInfo() {
    // 记录同步用户信息任务开始的日志
    logger.info("LdapSyncTask syncUserInfo start");

    // 创建一个过滤器,用于筛选出对象类为"person"的条目
    AndFilter filter = new AndFilter();
    filter.and(new EqualsFilter("objectClass", "person"));

    // 从LDAP中搜索符合条件的用户信息,并使用LdapUserAttributeMapper将结果映射为LdapUser对象列表
    List<LdapUser> users = ldapTemplate.search("", filter.encode(), new LdapUserAttributeMapper());

    // 如果没有找到任何用户,记录日志并返回
    if (users == null || users.isEmpty()) {
        logger.info("LdapSyncTask syncUserInfo users is null");
        return;
    }

    // 查询系统中的用户列表
    List<Map<String, Object>> sysUserList = iLdapSyncDao.queryFactoryUserList();

    // 将系统中启用的用户(useflag为1)转换为Map,键为用户代码,值为用户名
    Map<String, String> userMap = sysUserList.stream()
            .filter(map -> "1".equals(StringUtils.getStringFromMap(map, "useflag")))
            .collect(Collectors.toMap(map -> StringUtils.getStringFromMap(map, "code"), map -> StringUtils.getStringFromMap(map, "name")));

    // 将系统中禁用的用户(useflag为0)转换为Map,键为用户代码,值为用户名
    Map<String, String> delUserMap = sysUserList.stream()
            .filter(map -> "0".equals(StringUtils.getStringFromMap(map, "useflag")))
            .collect(Collectors.toMap(map -> StringUtils.getStringFromMap(map, "code"), map -> StringUtils.getStringFromMap(map, "name")));

    // 加密默认密码
    String password = BambooPasswordEncoder.encrypt(defaultPasswd);

    // 为每个用户设置加密后的密码和工厂ID,并记录日志
    users.forEach(user -> {
        user.setPassword(password);
        user.setFactoryId(factoryId);
        logger.info("LdapSyncTask syncUserInfo user:{}", user);
    });

    // 将用户按SAMAccountName分组
    Map<String, List<LdapUser>> newUserMap = users.stream()
            .collect(Collectors.groupingBy(LdapUser::getSAMAccountName));

    // 初始化插入和更新的用户列表
    List<LdapUser> insertUserList = new ArrayList<>();
    List<LdapUser> updateUserList = new ArrayList<>();

    // 遍历分组后的用户列表
    for (Map.Entry<String, List<LdapUser>> entry : newUserMap.entrySet()) {
        String code = entry.getKey();

        // 如果用户在禁用用户列表中,则将其加入更新列表
        if (delUserMap.containsKey(code)) {
            updateUserList.addAll(entry.getValue());
        } 
        // 如果用户不在系统用户列表中,则将其加入插入列表
        else if (!userMap.containsKey(code)) {
            insertUserList.addAll(entry.getValue());
        }
    }

    // 如果有需要插入的用户,调用DAO方法插入用户信息
    if (!insertUserList.isEmpty()) {
        iLdapSyncDao.insertUserInfo(insertUserList);
    } else {
        logger.info("LdapSyncTask syncUserInfo insertUserList isEmpty");
    }

    // 如果有需要更新的用户,调用DAO方法更新用户信息
    if (!updateUserList.isEmpty()) {
        iLdapSyncDao.updateUserInfo(updateUserList);
    } else {
        logger.info("LdapSyncTask syncUserInfo updateUserList isEmpty");
    }

    // 记录同步用户信息任务结束的日志
    logger.info("LdapSyncTask syncUserInfo end");
}

七、单点登录案例

本案例展示了通过一个spring服务进行鉴权中转,令域服务与原有系统之间进行对接,实现单点登录的过程

  1. 首先将系统所部署的服务器(下文称系统服务器)加入到域中
  2. 在域服务器上生成系统服务器账户密钥表
 ktpass /out c:\admin1.keytab /mapuser [email protected] /princ HTTP/Admin1.test.com:[email protected] /pass Shzy@2024 /ptype KRB5_NT_PRINCIPAL /crypto All

ktpass命令(Kerberos Key Distribution Center Passwd)是Windows Server上的一个命令行工具,用于创建和管理Kerberos密钥表(Keytab)
out: 文件输出路径
mapuser: 将主体(上述)映射到此用户帐户(默认:不映射)
princ: 主体名称(user@REALM)即服务地址 通常格式为 service/account@REALM
pass: 要使用的密码
ptype: 所涉及的主体类型
ptype: KRB5_NT_PRINCIPAL:常规主体类型--推荐
ptype: KRB5_NT_SRV_INST:用户服务实例
ptype: KRB5_NT_SRV_HST:主机服务实例
crypt: 指定要使用的加密类型
  1. 编写krb5.conf 和login.conf 并作为启动参数加入
# krb5.conf
# [libdefaults] 部分定义了默认的配置选项
[libdefaults]
  # 设置默认的realm
  default_realm = TEST.COM
  # 是否通过DNS查找realm,默认为false
  dns_lookup_realm = true
  # 是否通过DNS查找KDC,默认为true
  dns_lookup_kdc = true
  # 票据的有效时间
  ticket_lifetime = 24h
  # 票据的续订时间
  renew_lifetime = 7d
  # 是否允许票据可转发
  forwardable = true

  default_keytab_name = D:\\test.com\\krb5.keytab\\admin1.keytab


# [realms] 部分定义了realm的具体配置
[realms]
  # 设置KDC的位置
  TEST.COM = {
    # KDC服务器的地址
    kdc = ADServer.test.com
    # KDC管理员服务器的地址
    admin_server = ADServer.test.com
  }

# [domain_realm] 部分定义了域名到realm的映射
[domain_realm]
  # 映射域名到realm
  .test.com = TEST.COM
  test.com = TEST.COM

# login.conf 
spnego-client {
    com.sun.security.auth.module.Krb5LoginModule required;
};

spnego-server {
	com.sun.security.auth.module.Krb5LoginModule required
	isInitiator=false
	storeKey=true
	useKeyTab=true
	debug=true
};

启动参数

-Djava.security.krb5.conf=classpath:krb5.conf -Djava.security.auth.login.config=classpath:login.conf -Dsun.security.krb5.debug=true -Dsun.security.spnego.debug=true -Dsun.security.krb5.rcache=none 
  1. 配置SPNEGO认证相关的安全措施
@Configuration
@EnableWebMvcSecurity
public class SpnegoConfig extends WebSecurityConfigurerAdapter {

    @Value("${app.service-principal}")
    private String servicePrincipal; // 服务主体名称

    @Value("${app.keytab-location}")
    private String keytabLocation; // keytab文件位置

    @Value("${app.mes.login}")
    private String mesUrl; // MES登录URL

    /**
     * 配置HTTP安全策略。
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable(); // 禁用CSRF保护
        http.formLogin().disable(); // 禁用表单登录
        http
                .exceptionHandling()
                .authenticationEntryPoint(spnegoEntryPoint())// 设置入口点
                .and()
                .authorizeRequests()
                .antMatchers("/", "/login", "/logout").permitAll()// 允许访问特定路径
                .anyRequest().authenticated()// 所有其他请求都需要认证
                .and()
                .formLogin() // 表单登录配置
                .and()
                .logout()
                .logoutUrl("/home")// 注销URL
//                .logoutSuccessUrl("/index")
                .invalidateHttpSession(true) // 使会话无效
                .deleteCookies("JSESSIONID")// 删除指定的cookie
                .clearAuthentication(true)// 清除认证信息
                .permitAll() // 允许所有用户访问注销端点
                .and()
                .addFilterBefore(
                        spnegoAuthenticationProcessingFilter(authenticationManagerBean()),// 添加过滤器
                        BasicAuthenticationFilter.class); // 在Basic认证过滤器之前

//        http.addFilterBefore(spnegoLogoutFilter(), LogoutFilter.class);
    }

    /**
     * 创建一个LogoutFilter实例。
     */
    @Bean
    public LogoutFilter spnegoLogoutFilter() {
        // 自定义注销过滤器
        LogoutFilter logoutFilter = new LogoutFilter("/home", new SecurityContextLogoutHandler() {
            @Override
            public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
                // 设置响应头并重定向到首页
//                String user =  response.getHeader("WWW-Authenticate");
                response.addHeader("WWW-Authenticate", "Negotiate");
                response.setStatus(401);

                RequestDispatcher dispatcher = request.getRequestDispatcher("/home");
                try {
                    dispatcher.forward(request, response);
                    response.sendRedirect("/home");
                } catch (IOException e) {
                    throw new RuntimeException(e);
                } catch (ServletException e) {
                    throw new RuntimeException(e);
                }
                super.logout(request, response, authentication);
                logger.info("SPNEGO logout handler invoked.");
            }
        });
        return logoutFilter;
    }

    /**
     * 创建一个AuthenticationManager实例。
     */
    @Bean
	public AuthenticationManager authenticationManagerBean() throws Exception {
		List<AuthenticationProvider> providers = new ArrayList<>();
		providers.add(kerberosAuthenticationProvider());
		providers.add(kerberosServiceAuthenticationProvider());
		return new ProviderManager(providers);
	}


    /**
     * 创建一个KerberosAuthenticationProvider实例。
     */
    @Bean
    public KerberosAuthenticationProvider kerberosAuthenticationProvider() {
        KerberosAuthenticationProvider provider =
                new KerberosAuthenticationProvider();
        SunJaasKerberosClient client = new SunJaasKerberosClient();
        client.setDebug(true);
        provider.setKerberosClient(client);
        provider.setUserDetailsService(dummyUserDetailsService());
        return provider;
    }


    /**
     * 创建一个SpnegoEntryPoint实例。
     */
    @Bean
    public SpnegoEntryPoint spnegoEntryPoint() {
        return new SpnegoEntryPoint("/login");
    }

    /**
     * 创建一个SpnegoAuthenticationProcessingFilter实例。
     */
    @Bean
    public SpnegoAuthenticationProcessingFilter spnegoAuthenticationProcessingFilter(
            AuthenticationManager authenticationManager) {
        SpnegoAuthenticationProcessingFilter filter =
                new SpnegoAuthenticationProcessingFilter();
        filter.setAuthenticationManager(authenticationManager);
        filter.setSuccessHandler(new CustomAuthenticationSuccessHandler(mesUrl));
        filter.setFailureHandler(new CustomAuthenticationFailureHandler());
        return filter;
    }

    /**
     * 创建一个KerberosServiceAuthenticationProvider实例。
     */
    @Bean
    public KerberosServiceAuthenticationProvider kerberosServiceAuthenticationProvider() {
        KerberosServiceAuthenticationProvider provider =
                new KerberosServiceAuthenticationProvider();
        provider.setTicketValidator(sunJaasKerberosTicketValidator());
        provider.setUserDetailsService(dummyUserDetailsService());
        return provider;
    }

    /**
     * 创建一个SunJaasKerberosTicketValidator实例。
     */
    @Bean
    public SunJaasKerberosTicketValidator sunJaasKerberosTicketValidator() {
        SunJaasKerberosTicketValidator ticketValidator =
                new SunJaasKerberosTicketValidator();
        ticketValidator.setServicePrincipal(servicePrincipal);
        ticketValidator.setKeyTabLocation(new FileSystemResource(keytabLocation));
        ticketValidator.setDebug(true);
        return ticketValidator;
    }

    /**
     * 创建一个DummyUserDetailsService实例。
     */
    @Bean
    public DummyUserDetailsService dummyUserDetailsService() {
        return new DummyUserDetailsService();
    }

}
  1. 实现了一个简单的用户详细服务,用于从配置中获取用户信息。
/**
 * 实现了一个简单的用户详细服务,用于从配置中获取用户信息。
 */
public class DummyUserDetailsService implements UserDetailsService {

    @Value("${app.ad-domainSuffix}")
    private String domainSuffix;

    /**
     * 根据用户名加载用户详细信息。
     *
     * @param username 用户名
     * @return 用户详细信息对象
     * @throws UsernameNotFoundException 如果找不到用户
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 移除域后缀
        username = username.replaceAll(domainSuffix, "");
        // 创建一个简单的用户对象
        return new User(
                username, // 用户名
                "notUsed", // 密码(这里不实际使用)
                true, // 账户是否未过期
                true, // 账户是否未锁定
                true, // 凭证是否未过期
                true, // 账户是否启用
                AuthorityUtils.createAuthorityList("ROLE_USER")); // 角色列表
    }

}
  1. 自定义身份验证成功处理程序,用于处理身份验证成功后的重定向(重定向到原有系统登录接口)。
/**
 2. 自定义身份验证成功处理程序,用于处理身份验证成功后的重定向。
 */
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    /**
     * 构造函数,初始化 URL。
     *
     * @param mesUrl URL 地址
     */
    public CustomAuthenticationSuccessHandler(String url) {
        this.url = url;
    }

    private String url;

    /**
     * 在身份验证成功时调用此方法。
     *
     * @param httpServletRequest  HTTP 请求
     * @param httpServletResponse HTTP 响应
     * @param authentication      身份验证对象
     * @throws IOException        如果发生 I/O 错误
     * @throws ServletException   如果发生 Servlet 异常
     */
    @Override
    public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
        // 获取当前认证的用户对象
        User user = (User) authentication.getPrincipal();
        // 加密用户名 使用对称加密,避免明文传输
        String accoutName = "";
        try {
            accoutName = SymmetricEncryptionUtil.encrypt(user.getUsername());
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        // 构建重定向 URL
        String redirectUrl = mesUrl + "?accoutname="+accoutName;
        // 重定向到  URL
        httpServletResponse.sendRedirect(redirectUrl);
    }
}
  1. 自定义身份验证失败处理程序,用于处理身份验证失败的情况。
/**
 1. 自定义身份验证失败处理程序,用于处理身份验证失败的情况。
 */
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
    /**
     * 当身份验证失败时调用此方法。
     *
     * @param httpServletRequest  HTTP 请求
     * @param httpServletResponse HTTP 响应
     * @param e                   身份验证异常
     * @throws IOException        如果发生 I/O 错误
     * @throws ServletException   如果发生 Servlet 异常
     */
    @Override
    public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        // 设置响应头为 SPNEGO 协议
        httpServletResponse.addHeader("WWW-Authenticate", "Negotiate");
        // 设置 HTTP 状态码为 401(未授权)
        httpServletResponse.setStatus(401);
        // 获取请求调度器以转发到登录页面
        RequestDispatcher dispatcher = httpServletRequest.getRequestDispatcher("/login");
        // 将请求和响应转发到登录页面
        dispatcher.forward(httpServletRequest, httpServletResponse);

    }
}
  1. 此时用户登录电脑系统后打开系统网站仍需输入账号密码,此密码为域账户的用户名与密码,也可实现登录域账户即可登录系统。如想不输入账户密码,直接使用登录电脑的账户进行登录系统,按照下面步骤配置即可。
    Step 1:打开控制面板\网络和 Internet
    在这里插入图片描述
    Step 2: 点击“Internet选项”,点击“安全”选项卡,选择需要控制的范围,点击“自定义级别”,找到并选中“自动使用当前用户名和密码登录”即可
    在这里插入图片描述

标签:AD,登录,Kerberos,用户,SpringSecurity,new,true,public
From: https://blog.csdn.net/qq_43834455/article/details/142988026

相关文章

  • 【DBA Part03】国产Linux上Oracle RAC安装-升级-ADG-迁移
    本阶段内容如下:01.国产统信UOS-Oracle19c安装配置02.国产龙蜥AnolisOS-Oracle19cRAC集群安装配置03.Linux-Oracle11gR2数据库升级到Oracle19C04.Linux-Oracle11gR2RAC数据库升级到Oracle19cRAC05.Linux-Oracle19cADG容灾配置(1+1+1级联)06.OracleXTTS跨平台数据库迁移0......
  • k8s和ipvs、lvs、ipvsadm,iptables,底层梳理,具体是如何实现的
    计算节点的功能:提供容器运行的环境kube-proxy的主要功能:术业有专攻,kube-proxy的主要功能可以概括为4个字网络规则那么kube-proxy自己其实是个daemonset控制器跑的每个节点上都有个的pod它负责网络规则其实呢它还是个小领导它不直接去搞网络规则而是告诉别人,网络规......
  • cad2018丢失vcomp140.dll怎么办?dll缺失的解决办法
    当您在使用AutoCAD2018时遇到“丢失vcomp140.dll”错误,这通常意味着系统中缺少VisualC++RedistributableforVisualStudio2015的组件。vcomp140.dll是MicrosoftVisualC++库的一部分,许多基于Windows的应用程序,包括AutoCAD,都需要这个库来运行。以下是一些解决此问题的步......
  • 7系XADC PL多通道采集
    关键词:XADC,PL,多通道,pynqz2不了解xadc基本信息的可以去这里了解开始IP核配置如果对IP核选项不了解的可以去这里查看Fig.BasicFig.ADCSetup这里选择了持续模式,也可以配置default这次我勾选了全部校正,同时没有勾选外部复用器还将ADCB掉电了Alarms页面依......
  • jmeter压测问题: JAVA.NET.BINDEXCEPTION: ADDRESS ALREADY IN USE: CONNECT
    1.报错信息:2. 问题排查  1)询问AI,说端口被占用。修改了jmeter的端口号后,仍是不行  2)最后找到一篇博客,真的解决了问题     我只进行了,增大端口号,减少Time_Wait, Close_WAIT没有处理,仍解决了此问题 ......
  • Thread类的基本用法
    一、线程创建1.继承Thread,重写runpackagedemo1;//继承Thread,重写runclassMyThreadextendsThread{@Overridepublicvoidrun(){System.out.println("继承Thread,重写run");}}publicclassDemo1{publicstaticvoidmain(Str......
  • How to Download YouTube Videos for Free
     However,therearetimeswhenyoumaywanttodownloadYouTubevideosforofflineviewing,suchaswhenyou'retravelingwithoutareliableinternetconnectionorwanttosaveafavoritevideoforlater.Inthisarticle,wewillexploresomemethod......
  • 矢量图形处理软件Adobe Illustrator (Ai) 下载安装(附win/mac安装包)
    目录一、软件简介主要功能应用领域二、系统要求Windows系统要求macOS系统要求三、安装步骤1.获取安装包2.安装软件3.配置与启动一、软件简介AdobeIllustrator,简称Ai,是一款由Adobe公司开发的矢量图形处理软件。它广泛应用于出版、多媒体和在线图像的各个领......
  • ADI 亚德诺半导体 Analog Devices 产品的应用介绍和物料推荐(一)
    各位电子行业的伙伴们!今天来聊聊ADI亚德诺半导体。ADI可是全球知名的半导体公司哦!它专注于模拟信号处理,在通信、工业、汽车等众多领域都有卓越表现。ADI的产品以高性能著称,在通信领域提供的放大器、数据转换器、射频芯片等,能满足通信系统对信号处理的高要求。在工业领域,其......
  • [Paper Reading] Decoding Surface Touch Typing from Hand-Tracking
    目录DecodingSurfaceTouchTypingfromHand-TrackingTL;DRMethodHTSkeletonSequence->TextTEXTDECODINGDATACOLLECTIONQ&AExperiment物理键盘与虚拟键盘对比对比不同MotionModel效果可视化总结与发散相关链接资料查询DecodingSurfaceTouchTypingfromHand-Tracking......