zoukankan      html  css  js  c++  java
  • Java-Security(三):加密的用法、PasswordEncoder类源码分析

    在第一篇文章,我们展示了一个demo,其中讲到了对用户的密码进行了明文展示的用法,其实那么做是不安全的,在实际项目中往往会采用各种加密方法(比如:bcrypt,md5,sha1,sha2等)来实现对密码的保护。

    本片文章将会主要讲解如何在Spring Security实现对密码加密的各种用法,以及对BCrypt的用法进一步分析。

    概念

    Spring Security 为我们提供了一套加密规则和密码比对规则,org.springframework.security.crypto.password.PasswordEncoder 接口,该接口里面定义了三个方法。

    public interface PasswordEncoder {
        //加密(外面调用一般在注册的时候加密前端传过来的密码保存进数据库)
        String encode(CharSequence rawPassword);
    
        //加密前后对比(一般用来比对前端提交过来的密码和数据库存储密码, 也就是明文和密文的对比)
        boolean matches(CharSequence rawPassword, String encodedPassword);
    
        //是否需要再次进行编码, 默认不需要
        default boolean upgradeEncoding(String encodedPassword) {
            return false;
        }
    }

    在Spring Security下 PasswordEncoder 的实现类包含:

     其中常用到的分别有下面这么几个:   

        BCryptPasswordEncoder:Spring Security 推荐使用的,使用BCrypt强哈希方法来加密。
        MessageDigestPasswordEncoder:用作传统的加密方式加密(支持 MD5、SHA-1、SHA-256...)
        DelegatingPasswordEncoder:最常用的,根据加密类型id进行不同方式的加密,兼容性强
        NoOpPasswordEncoder:明文, 不做加密
        其他

    Spring Security中加密的用法:

    使用bcrypt bean

    applicationContext-shiro.xml中配置:

        <bean id="secureRandom" class="java.security.SecureRandom"/>
        <bean id="bCryptPasswordEncoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder">
            <constructor-arg name="version" value="$2A" /> <!-- salt随机生成版本 默认$2A-->
            <constructor-arg name="strength" value="10"/> <!-- 使用salt进行加密迭代次数,默认10-->
            <constructor-arg name="random" ref="secureRandom"/> <!-- 随机算法 -->
        </bean>
    
        <security:authentication-manager>
            <security:authentication-provider>
                <security:user-service>
                    <security:user name="user" password="$2a$10$LCe6jsoHUrEvWI1KURrqbu/xfuPU5aZj2RkPTVS0d7MUJiT55Lt/y"
                                       authorities="ROLE_USER"/>
                    <security:user name="admin" password="$2a$10$BR3Np37NbmtWHqpSZE6AMeCMG4Rm.UOUEZ3dYrW3oUXHNuSBXjDwi"
                                   authorities="ROLE_USER, ROLE_ADMIN"/>
                </security:user-service>
                <security:password-encoder ref="bCryptPasswordEncoder"/>
            </security:authentication-provider>
        </security:authentication-manager>

    说明:

    1)需要配置 bCryptPasswordEncoder的bean,在该bean配置时,可以指定其构造函数相关参数:

    version:salt随机生成版本,默认:采用 BCryptVersion.$2A.getVersion();

    strength:使用salt进行加密迭代次数,默认:10;

    random:随机算法,默认:new SecureRandom()

    2)需要在<authentication-provider>标签下的<password-encoder ref=''/>指定该bean。

    密码加密用法:

            // BCrypt加密与验证,内部默认:
            PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
            System.out.println("passwordEncoder 123456:" + passwordEncoder.encode("123456"));
            System.out.println("passwordEncoder 123456:" + passwordEncoder.encode("123456"));
            // BCrypt密文解析
    
            //参数解释
            //1)2a:加密算法版本号。
            //2)10:加密轮次,默认为10,数值越大,加密时间和越难破解呈指数增长。可在BCryptPasswordEncoder构造参数传入。
            //3)密码加密:前面的内容是盐,后面的内容才是真正的密文。
            //以下方式可以更清晰的看出盐和全文。
            String salt = BCrypt.gensalt(BCryptPasswordEncoder.BCryptVersion.$2A.getVersion(), 10, new SecureRandom());
            String result = BCrypt.hashpw("123456", salt);//全文
            System.out.println("salt:" + salt + ",salt's length:" + salt.length()); // salt长度是29
            System.out.println("result:" + result);

    在对密码加密时,可以采用上边这3种方法:

    1)BCryptPasswordEncoder的实例,直接调用 encode方法,此时version,strlength,random都采用默认值。

    2)也可以使用BCrypt来实现,实际上上边BCypt的操作就是BCryptPasswordEncoder#encode内部的方法实现。

    3)另外,也可以直接在代码中引入applicaitonContext-security.xml中的md5 bean到代码中 @Resources("bCryptPasswordEncoder") private PasswordEncoder bCryptPasswordEncoder;

    使用md5 bean

    applicationContext-shiro.xml中配置

        <bean id="md5" class="org.springframework.security.crypto.password.MessageDigestPasswordEncoder">
            <constructor-arg name="algorithm" value="MD5"/>
            <property name="iterations" value="10"/>
        </bean>
    
        <security:authentication-manager>
            <security:authentication-provider>
                <security:user-service>
                    <security:user name="user" password="{sBNW6rB991DqeGbH6ikVJcTe6XwPoHtPW/iyWkwbrF4=}38dee1075a2eaa458bc3fb7e7a945ef8"
                                   authorities="ROLE_USER"/>
                    <security:user name="admin" password="{sBNW6rB991DqeGbH6ikVJcTe6XwPoHtPW/iyWkwbrF4=}38dee1075a2eaa458bc3fb7e7a945ef8"
                                   authorities="ROLE_USER, ROLE_ADMIN"/>
                </security:user-service>
                <security:password-encoder ref="md5"/>
            </security:authentication-provider>
        </security:authentication-manager>

    说明:

    1)需要配置md5 bean,在配置bean时,必须指定MessageDigestPasswordEncoder的构造函数参数:algorithm:指定算法类型,这里是MD5;

    2)另外,md5#iterations参数:迭代次数如果不指定,默认为1,这里指定为10;

    2)需要在<authentication-provider>标签下的<password-encoder ref=''/>指定该bean。

    密码加密用法:

            MessageDigestPasswordEncoder md5 = new MessageDigestPasswordEncoder("MD5");
            md5.setIterations(10);
            md5Password = "{MD5}" + md5.encode("password");
            System.out.println("MD5密码:" + md5Password);
            System.out.println("MD5密码对比:" + passwordEncoder.matches("password", md5Password));

    在对密码加密时,可以采用上边方法:

    1)MessageDigestPasswordEncoder的实例,可以设置其迭代次数。

    2)另外,也可以直接在代码中引入applicaitonContext-security.xml中的md5 bean到代码中 @Resources("md5") private PasswordEncoder md5;

    缺省password-encoder(DelegatingPasswordEncoder)

    当缺省<security:password-encoder ref="xxx"/>时,Spring Security会使用系统内置的DelegatingPasswordEncoder,自动动适配 PasswordEncoder。

    applicationContext-shiro.xml中配置:

        <security:authentication-manager>
            <security:authentication-provider>
                <security:user-service>
                    <!-- noop NoOpPasswordEncoder.getInstance()-->
                    <security:user name="user" password="{noop}userpwd" authorities="ROLE_USER"/>
                    <security:user name="admin" password="{noop}adminpwd" authorities="ROLE_USER, ROLE_ADMIN"/>
                      <!-- bcrypt new BCryptPasswordEncoder() -->
                    <security:user name="user1" password="{bcrypt}$2a$10$LCe6jsoHUrEvWI1KURrqbu/xfuPU5aZj2RkPTVS0d7MUJiT55Lt/y"
                                   authorities="ROLE_USER"/>
                    <security:user name="admin1" password="{bcrypt}$2a$10$BR3Np37NbmtWHqpSZE6AMeCMG4Rm.UOUEZ3dYrW3oUXHNuSBXjDwi"
                                   authorities="ROLE_USER, ROLE_ADMIN"/>
                    <!-- MD5 new MessageDigestPasswordEncoder("MD5") -->
                    <security:user name="user2" password="{MD5}{sBNW6rB991DqeGbH6ikVJcTe6XwPoHtPW/iyWkwbrF4=}38dee1075a2eaa458bc3fb7e7a945ef8"
                                   authorities="ROLE_USER"/>
                    <security:user name="admin2" password="{MD5}{sBNW6rB991DqeGbH6ikVJcTe6XwPoHtPW/iyWkwbrF4=}38dee1075a2eaa458bc3fb7e7a945ef8"
                                   authorities="ROLE_USER, ROLE_ADMIN"/>
                </security:user-service>
                <security:password-encoder ref="md5"/>
            </security:authentication-provider>
        </security:authentication-manager>

    1)如果在<security:authentication-provider>下指定了<security:password-encoder ref="xxx"/>就不需要在<security:user name="xxx" password="yyy"authorities="zzz"/>中的 password 前边加上加密类型({noop}{bcrypt}{MD5}等),否则会导致密码验证失败;
    2)如果在<security:authentication-provider>下未指定<security:password-encoder ref="xxx"/>就必须要在<security:user name="xxx" password="yyy" authorities="zzz"/>中的 password 前边加上加密类型({noop}、{bcrypt}、{MD5}等),否则会导致密码验证失败。因为此时验证密码是否成功,会调用org.springframework.security.crypto.password.DelegatingPasswordEncoder.java中的#encode方法、#matches方法,而DelegatingPasswordEncoder中查找密码加密对应的PasswordEncoder时,会根据密码前缀的加密类型查找:如果查找失败,会导致查找不到delegate,也就是delegate为null。

    密码加密、解密代码示例:

            PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
    
            // 此时encode内部使用的就是 BCryptPasswordEncoder
            String encode = passwordEncoder.encode("password");
            System.out.println("bcrypt密码对比:" + passwordEncoder.matches("password", encode));
    
            // 不带salt,迭代
            String md5NoSaltPassword = "{MD5}" + DigestUtils.md5DigestAsHex("password".getBytes());
            System.out.println("MD5(不含salt、iterations)密码对比:" + passwordEncoder.matches("password", md5NoSaltPassword));
    
            // 待salt,迭代
            MessageDigestPasswordEncoder md5SaltIterationsPassword = new MessageDigestPasswordEncoder("MD5");
            md5SaltIterationsPassword.setIterations(1);
            String md5Password = "{MD5}" + md5SaltIterationsPassword.encode("password");
            System.out.println("MD5(包含salt、iterations)密码对比:" + passwordEncoder.matches("password", md5Password));
    
            String noopPassword = "{noop}password";
            System.out.println("noop密码对比:" + passwordEncoder.matches("password", noopPassword));

    输出结果:

    bcrypt密码对比:true
    MD5(不含salt、iterations)密码对比:true
    MD5(包含salt、iterations)密码对比:true
    noop密码对比:true

    DelegatingPasswordEncoder类讲解

    构造函数初始化

    DelegatingPasswordEncoder本身就是继承了 PasswordEncoder 类,因此也可以在applicationContext-shiro.xml中定义为bean,在<security:authentication-provider>下指定<security:password-encoder ref="xxx"/>的 ref 为该bean。

    但是,实际上这么做是没有意义,因为在<security:authentication-provider>下不指定<security:password-encoder ref="xxx"/>时,系统会缺省的采用DelegatingPasswordEncoder作为PasswordEncoder的实现。

        public DelegatingPasswordEncoder(String idForEncode, Map<String, PasswordEncoder> idToPasswordEncoder) {
            if (idForEncode == null) {
                throw new IllegalArgumentException("idForEncode cannot be null");
            } else if (!idToPasswordEncoder.containsKey(idForEncode)) {
                throw new IllegalArgumentException("idForEncode " + idForEncode + "is not found in idToPasswordEncoder " + idToPasswordEncoder);
            } else {
                Iterator var3 = idToPasswordEncoder.keySet().iterator();
    
                while(var3.hasNext()) {
                    String id = (String)var3.next();
                    if (id != null) {
                        if (id.contains("{")) {
                            throw new IllegalArgumentException("id " + id + " cannot contain " + "{");
                        }
    
                        if (id.contains("}")) {
                            throw new IllegalArgumentException("id " + id + " cannot contain " + "}");
                        }
                    }
                }
    
                this.idForEncode = idForEncode;
                this.passwordEncoderForEncode = (PasswordEncoder)idToPasswordEncoder.get(idForEncode);
                this.idToPasswordEncoder = new HashMap(idToPasswordEncoder);
            }
        }

    说明:

    1)调用DelegatingPasswordEncoder#constructor()类是PasswordEncoderFactories.java

    public class PasswordEncoderFactories {
        public static PasswordEncoder createDelegatingPasswordEncoder() {
            String encodingId = "bcrypt";
            Map<String, PasswordEncoder> encoders = new HashMap();
            encoders.put(encodingId, new BCryptPasswordEncoder());
            encoders.put("ldap", new LdapShaPasswordEncoder());
            encoders.put("MD4", new Md4PasswordEncoder());
            encoders.put("MD5", new MessageDigestPasswordEncoder("MD5"));
            encoders.put("noop", NoOpPasswordEncoder.getInstance());
            encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
            encoders.put("scrypt", new SCryptPasswordEncoder());
            encoders.put("SHA-1", new MessageDigestPasswordEncoder("SHA-1"));
            encoders.put("SHA-256", new MessageDigestPasswordEncoder("SHA-256"));
            encoders.put("sha256", new StandardPasswordEncoder());
            encoders.put("argon2", new Argon2PasswordEncoder());
            return new DelegatingPasswordEncoder(encodingId, encoders);
        }
    
        private PasswordEncoderFactories() {
        }
    }

    上边代码是spring security系统中唯一用来初始化DelegatingPasswordEncoder的地方。

    • 1)在Spring Security系统将AuthenticationProvider的bean初始化到Spring容器时会调用PasswordEncoderFactories#createDelegatingPasswordEncoder()方法初始化DelegatingPasswordEncoder;
    • 2)这个过程也就是给AutheticationProvider#passwordEncoder赋值的触发点;
    • 3)当然如果在<security:authentication-provider>下指定<security:password-encoder ref="xxx"/>的 ref 不为DelegatingPasswordEncoder时,也将不会调用PasswordEncoderFactories#createDelegatingPasswordEncoder()方法

    2)idToPasswordEncoder属性:DelegatingPasswordEncoder是一个能适配多种PasswordEncoder的委托类,其内部定义了一个Map<String,PasswordEncoder>集合:

    key为:PasswordEncoder的别名;
    value为:PasswordEncoder的具体实现类。

        private final Map<String, PasswordEncoder> idToPasswordEncoder;

    idToPasswordEncoder用来托管PassswordEncoder的实现,这个类是在DelegatingPasswordEncoder#constructor中被传递初始化的。

    3)idForEncode属性通过PasswordEncoderFactories#createDelegatingPasswordEncoder()中初始化DelegatingPasswordEncoder的代码,可以知道idForEncode的值是“bcrypt”;

    4)passwordEncoderForEncode属性:就是BCryptPasswordEncoder对象。

    encode加密

        public String encode(CharSequence rawPassword) {
            return "{" + this.idForEncode + "}" + this.passwordEncoderForEncode.encode(rawPassword);
        }

    说明:

    1)通过上边DelegatingPasswordEncoder#constructor()代码可以知道:passwordEncoderForEncode属性就是BCryptPasswordEncoder对象

    2)DelegatingPasswordEncoder#encode()方法:实际上就是"bcrypt"加密算法。这点十分重要,往往也是其特殊之处,需要使用者牢记。

    3)rawPassword参数:待加密密码明文。

    matches匹配密码

        public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) {
            if (rawPassword == null && prefixEncodedPassword == null) {
                return true;
            } else {
                // 根据密文前缀查找 delegate
                String id = this.extractId(prefixEncodedPassword);
                PasswordEncoder delegate = (PasswordEncoder)this.idToPasswordEncoder.get(id);
                if (delegate == null) { // delegate查找失败
                    return this.defaultPasswordEncoderForMatches.matches(rawPassword, prefixEncodedPassword);
                } else {
                    String encodedPassword = this.extractEncodedPassword(prefixEncodedPassword);
                    return delegate.matches(rawPassword, encodedPassword);
                }
            }
        }

    说明:

    1)rawPassword参数:密码明文;

    2)prefixEncodedPassword参数:带有加密类型的密码密文,必须带有使用的PasswordEncoder类型(PasswordEncoderFactories#createDelegatingPasswordEncoder()中map#key);

    格式举例:

    {noop}password
    {bcypt}$2a$10$IK/02aEUVRBaeoQsvN.VluPLqNKZ2ZwwTRmAAWXmlnCU5DAjmjtRC
    {MD5}5f4dcc3b5aa765d61d8327deb882cf99
    {MD5}{L5M7tjEyGdBtyFCyk0pBXOLLFi3AOMEBZqdRDTAwV6c=}c05b48c699659f56462bbed387485cc6

    3)当没有指定密码加密类型({bcypt}等)时,会抛出异常:

    java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"
        org.springframework.security.crypto.password.DelegatingPasswordEncoder$UnmappedIdPasswordEncoder.matches(DelegatingPasswordEncoder.java:250)
        org.springframework.security.crypto.password.DelegatingPasswordEncoder.matches(DelegatingPasswordEncoder.java:198)
        org.springframework.security.authentication.dao.DaoAuthenticationProvider.additionalAuthenticationChecks(DaoAuthenticationProvider.java:90)
        org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider.authenticate(AbstractUserDetailsAuthenticationProvider.java:166)
        org.springframework.security.authentication.ProviderManager.authenticate(ProviderManager.java:175)
        。。。

    BCryptPasswordEncoder类讲解

    属性:

        private Pattern BCRYPT_PATTERN;
        private final Log logger;
        private final int strength;
        private final BCryptPasswordEncoder.BCryptVersion version;
        private final SecureRandom random;

    说明:

    1)BCRYPT_PATTERN:bcrypt密文格式验证正则表达式;

    2)logger:日志操作类;

    3)strlength:生成salt迭代次数;

    4)version:生成salt采用的版本;

    5)random:随机生成slat实现。

    构造函数:

        public BCryptPasswordEncoder() {
            this(-1);
        }
    
        public BCryptPasswordEncoder(int strength) {
            this(strength, (SecureRandom)null);
        }
    
        public BCryptPasswordEncoder(BCryptPasswordEncoder.BCryptVersion version) {
            this(version, (SecureRandom)null);
        }
    
        public BCryptPasswordEncoder(BCryptPasswordEncoder.BCryptVersion version, SecureRandom random) {
            this(version, -1, random);
        }
    
        public BCryptPasswordEncoder(int strength, SecureRandom random) {
            this(BCryptPasswordEncoder.BCryptVersion.$2A, strength, random);
        }
    
        public BCryptPasswordEncoder(BCryptPasswordEncoder.BCryptVersion version, int strength) {
            this(version, strength, (SecureRandom)null);
        }
    
        public BCryptPasswordEncoder(BCryptPasswordEncoder.BCryptVersion version, int strength, SecureRandom random) {
            this.BCRYPT_PATTERN = Pattern.compile("\A\$2(a|y|b)?\$(\d\d)\$[./0-9A-Za-z]{53}");
            this.logger = LogFactory.getLog(this.getClass());
            if (strength == -1 || strength >= 4 && strength <= 31) {
                this.version = version;
                this.strength = strength == -1 ? 10 : strength;
                this.random = random;
            } else {
                throw new IllegalArgumentException("Bad strength");
            }
        }

    构造函数重构的比较多,在DelegatingPasswordEncoder中使用的就是第一个构造函数,此时属性会赋值默认值:

    1)BCRYPT_PATTERN:bcrypt密文格式验证正则表达式,默认值:Pattern.compile("\A\$2(a|y|b)?\$(\d\d)\$[./0-9A-Za-z]{53}")

    2)strlength:生成salt迭代次数,默认值:10;

    3)version:生成salt采用的版本,默认值:BCryptPasswordEncoder.BCryptVersion.$2A

    4)random:随机生成slat实现,默认值:空。

    encode方法:

        public String encode(CharSequence rawPassword) {
            String salt;
            if (this.random != null) {
                salt = BCrypt.gensalt(this.version.getVersion(), this.strength, this.random);
            } else {
                salt = BCrypt.gensalt(this.version.getVersion(), this.strength);
            }
    
            return BCrypt.hashpw(rawPassword.toString(), salt);
        }

    BCryptPasswordEncoder实际内部是使用 BCrypt 实现;

    matches方法:

        public boolean matches(CharSequence rawPassword, String encodedPassword) {
            if (encodedPassword != null && encodedPassword.length() != 0) {
                if (!this.BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
                    this.logger.warn("Encoded password does not look like BCrypt");
                    return false;
                } else {
                    return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
                }
            } else {
                this.logger.warn("Empty encoded password");
                return false;
            }
        }

    从BCRYPT_PATTERN的值"\A\$2(a|y|b)?\$(\d\d)\$[./0-9A-Za-z]{53}",可以发现另外一些密文分为3部分:

    • 第一部分:以$2a、$2y、$2b开头;
    • 第二部分:以$数字
    • 第三部分:以$开头后边附加.、/、数字、大写字母、小写字母组成的,且长度为53的字符串。

    测试代码:

        @Test
        public void testPwdEncoder() {
            // 
            // BCrypt加密与验证
            PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
            System.out.println("passwordEncoder 123456:" + passwordEncoder.encode("123456"));
            System.out.println("passwordEncoder 123456:" + passwordEncoder.encode("123456"));
            // BCrypt密文解析
    
            //在密文中包含3段内容,$是分隔符。
            //1)2a:加密算法版本号。
            //2)10:加密轮次,默认为10,数值越大,加密时间和越难破解呈指数增长。可在BCryptPasswordEncoder构造参数传入。
            //3)第3个$之后:前面的内容是盐,后面的内容才是真正的密文。
            //以下方式可以更清晰的看出盐和全文。
            String salt = BCrypt.gensalt(BCryptPasswordEncoder.BCryptVersion.$2A.getVersion(), 10, new SecureRandom());
            String result = BCrypt.hashpw("123456", salt);//全文
            System.out.println("salt:" + salt + ",salt's length:" + salt.length()); // salt长度是29
            System.out.println("result:" + result);
    
            salt = BCrypt.gensalt(BCryptPasswordEncoder.BCryptVersion.$2B.getVersion(), 11, new SecureRandom());
            result = BCrypt.hashpw("123456", salt);//全文
            System.out.println("salt:" + salt + ",salt's length:" + salt.length()); // salt长度是29
            System.out.println("result:" + result);
        }

    打印:

    passwordEncoder 123456:$2a$10$XSpVd/lavtejOXHeDGNMOe1zxgblnsXWoTi0DFD/vN4Z6EjH1r97q
    passwordEncoder 123456:$2a$10$I9zV9AbsEdi36s7ovTQ2hOhUczFP5CXybnyJv9aNY6Ae6qky9oouu
    salt:$2a$10$yg5TNGzmyNe0di70exM.vO,salt's length:29
    result:$2a$10$yg5TNGzmyNe0di70exM.vOWLx.lMiniZ/BOCoecIc5tF/Q0CvYUJa
    salt:$2b$11$ncuDpd17nju3d6auOrQAr.,salt's length:29
    result:$2b$11$ncuDpd17nju3d6auOrQAr.BZqNeyyVgqhb3gncQyRUvuKHzA2.FOS

    从上边代码测试会发现,BCryptPasswordEncoder 实际内部是使用 BCrypt 实现,另外从测试可以发现使用SpringSecurity缺省password encoder生成密文有以下规则:

    1)在密文中包含3段内容,
    2)2a:salt生成算法版本号;
    3)10:salt迭代次数,默认为10(取值范围是:[4,31]),数值越大,加密时间和越难破解呈指数增长。可在BCryptPasswordEncoder构造参数传入;
    4)第3个$之后:前面的内容是盐,后面的内容才是真正的密文;
    5)随机生成salt,且salt的长度为29;

  • 相关阅读:
    Java实现 LeetCode 69 x的平方根
    Java实现 LeetCode 68 文本左右对齐
    Java实现 LeetCode 68 文本左右对齐
    Java实现 LeetCode 68 文本左右对齐
    Java实现 LeetCode 67 二进制求和
    Java实现 LeetCode 67 二进制求和
    Java实现 LeetCode 67 二进制求和
    Java实现 LeetCode 66 加一
    Java实现 LeetCode 66 加一
    CxSkinButton按钮皮肤类
  • 原文地址:https://www.cnblogs.com/yy3b2007com/p/12203823.html
Copyright © 2011-2022 走看看