Spring Security 02 — Basic Introduction Part II

栏目: Java · 发布时间: 5年前

内容简介:书接上文,在上一篇中介绍了Spring Security的基本组件以及基本的认证流程,此篇介绍一下Spring Security的一些核心服务。Spring Security中还有许多十分重要的接口,特别是

Spring Security 02 — Basic Introduction Part II

Spring Security 5.1.4 RELEASE

书接上文,在上一篇中介绍了Spring Security的基本组件以及基本的认证流程,此篇介绍一下Spring Security的一些核心服务。

2 核心服务

Spring Security中还有许多十分重要的接口,特别是 AuthenticationManager , UserDetailsServiceAccessDecisionManager ,Spring Security提供了一些实现,用户亦可自己实现定制的认证授权机制。下面我们来具体看一下几个接口及Spring Security提供的实现,从而有助于了解在认证授权环节中它们的具体作用,以及如何使用。

2.1 AuthenticationManager, ProviderManager and AuthenticationProvider

AuthenticationManger 是一个接口,用来完成认证的逻辑,开发者可按照自己的项目设计需求进行实现。如果,我们希望能够组合使用多种认证服务,比如基于数据库和LDAP服务器的认证服务,Spring Security也是支持的。

ProviderManager 是Spring Security提供的一个实现,但是它本身不处理认证请求,而是将任务委托给一个配置好的 AuthenticationProvider 的列表,其中每一个 AuthenticationProvider 按序确认能否完成认证,每个provider如果认证失败,会抛出一个异常,如果认证通过,则会返回一个 Authentication 对象。

AuthenticationManager

AuthenticationManger 是一个接口,其中只有一个方法 authenticate ,用来尝试对传入的 Authentication 对象进行认证。

这里保留了源码中的一大段注释,其中包括了该接口的作用,以及在实现时应当注意的一些问题,这里不多做叙述,我们目前只需要了解这个接口的作用即可,感兴趣的同学可以自行阅读源码中的注释。

/**
 * Processes an {@link Authentication} request.
 *
 * @author Ben Alex
 */
public interface AuthenticationManager {
    // ~ Methods
    // ========================================================================================================
​
    /**
     * Attempts to authenticate the passed {@link Authentication} object, returning a
     * fully populated <code>Authentication</code> object (including granted authorities)
     * if successful.
     * <p>
     * An <code>AuthenticationManager</code> must honour the following contract concerning
     * exceptions:
     * <ul>
     * <li>A {@link DisabledException} must be thrown if an account is disabled and the
     * <code>AuthenticationManager</code> can test for this state.</li>
     * <li>A {@link LockedException} must be thrown if an account is locked and the
     * <code>AuthenticationManager</code> can test for account locking.</li>
     * <li>A {@link BadCredentialsException} must be thrown if incorrect credentials are
     * presented. Whilst the above exceptions are optional, an
     * <code>AuthenticationManager</code> must <B>always</B> test credentials.</li>
     * </ul>
     * Exceptions should be tested for and if applicable thrown in the order expressed
     * above (i.e. if an account is disabled or locked, the authentication request is
     * immediately rejected and the credentials testing process is not performed). This
     * prevents credentials being tested against disabled or locked accounts.
     *
     * @param authentication the authentication request object
     *
     * @return a fully authenticated object including credentials
     *
     * @throws AuthenticationException if authentication fails
     */
    Authentication authenticate(Authentication authentication)
            throws AuthenticationException;
}

ProviderManager

ProviderManagerAuthentication 的一个实现,并将具体的认证操作委托给一系列的 AuthenticationProvider 来完成,从而可以实现支持多种认证方式。为了帮助阅读和理解源码具体做了什么,这里删除了原来的一部分注释,并对重要的部分进行了注释说明。

public class ProviderManager implements AuthenticationManager, MessageSourceAware,
        InitializingBean {
​
    private static final Log logger = LogFactory.getLog(ProviderManager.class);
​
    private AuthenticationEventPublisher eventPublisher = new NullEventPublisher();
    private List<AuthenticationProvider> providers = Collections.emptyList();
    protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
    private AuthenticationManager parent;
    private boolean eraseCredentialsAfterAuthentication = true;
​
    /**
     * 用List<AuthenticationProvider>初始化一个ProviderManager
     * 也就是上文提到的,ProviderManager将具体的认证委托给不同的provider,从而支持不同的认证方式
     */      
    public ProviderManager(List<AuthenticationProvider> providers) {
        this(providers, null);
    }
​
    /**
     * 也可以为其设置一个父类
     */
    public ProviderManager(List<AuthenticationProvider> providers,
            AuthenticationManager parent) {
        Assert.notNull(providers, "providers list cannot be null");
        this.providers = providers;
        this.parent = parent;
        checkState();
    }
​
    public void afterPropertiesSet() throws Exception {
        checkState();
    }
​
    private void checkState() {
        if (parent == null && providers.isEmpty()) {
            throw new IllegalArgumentException(
                    "A parent AuthenticationManager or a list "
                            + "of AuthenticationProviders is required");
        }
    }
​
    /**
     * ProviderManager的核心方法,authentication方法尝试对传入的Authentication对象进行认证,传入的Authentication是
     * 以用户的提交的认证信息,比如用户名和密码,创建的一个Authentication对象。
     * 
     * 会依次询问各个AuthenticationProvider,当provider支持对传入的Authentication认证,
     * 便会尝试使用该provider进行认证。如果有多个provider都支持认证传入的Authentication对象,
     * 则只会使用第一个支持的provider进行认证。
     *
     * 一旦有一个provider认证成功了,便会忽略之前任何provider抛出的异常,之后的provider也不会再
     * 继续认证的尝试。
     *
     * 如果所有provider都认证失败,方法则会抛出最后一个provider抛出的异常。
     */
    public Authentication authenticate(Authentication authentication)
            throws AuthenticationException {
        Class<? extends Authentication> toTest = authentication.getClass();
        AuthenticationException lastException = null;
        AuthenticationException parentException = null;
        Authentication result = null;
        Authentication parentResult = null;
        boolean debug = logger.isDebugEnabled();
​
        // 依次使用各个provider尝试进行认证
        for (AuthenticationProvider provider : getProviders()) {
            // 如果provider不支持对传入的Authentication进行认证,则跳过。
            if (!provider.supports(toTest)) {
                continue;
            }
​
            if (debug) {
                logger.debug("Authentication attempt using "
                        + provider.getClass().getName());
            }
​
            try {
                // 调用provider的authenticate方法进行认证
                result = provider.authenticate(authentication);
                
                // 如果认证成功,则将authentication中用户的细节信息复制到result中
                // 然后跳出循环,不再尝试后面其他的provider
                if (result != null) {
                    copyDetails(authentication, result);
                    break;
                }
            }
            catch (AccountStatusException e) {
                prepareException(e, authentication);
                // 如果待认证的账号信息无误,但是账号本身异常,比如账号停用了,则抛出AccountStatusException异常,
                // 并通过prepareException方法,发布一个AbstractAuthenticationFailureEvent,避免继续尝试其他provider进行认证
                throw e;
            }
            catch (InternalAuthenticationServiceException e) {
                prepareException(e, authentication);
                throw e;
            }
            catch (AuthenticationException e) {
                // 如果该provider认证失败,捕获异常AuthenticationException后不抛出,继续尝试下一个provider
                // lastException会记录下最后一个认证失败的provider抛出的AuthenticationException异常。
                lastException = e;
            }
        }
​
        if (result == null && parent != null) {
            // 如果所有provider都没能认证成功,则交给父类尝试认证
            try {
                result = parentResult = parent.authenticate(authentication);
            }
            catch (ProviderNotFoundException e) {
                // 父类如果抛出该异常不做处理,因为后面有对子类抛出该异常的处理
            }
            catch (AuthenticationException e) {
                // 父类也没能认证成功,则最后一个异常为来自父类认证失败的异常
                lastException = parentException = e;
            }
        }
​
        if (result != null) {
            if (eraseCredentialsAfterAuthentication
                    && (result instanceof CredentialsContainer)) {
                // 认证成功,从Authentication中删除密码秘钥等敏感信息
                ((CredentialsContainer) result).eraseCredentials();
            }
​
            // 如果父类认证成功,则会发布一个AuthenticationSuccessEvent,
            // 这一步检查,防止子类重复发布
            if (parentResult == null) {
                eventPublisher.publishAuthenticationSuccess(result);
            }
            
            //返回的result为一个Authentication,其中包含了已认证用户的信息
            return result;
            
        }
​
        if (lastException == null) {
            lastException = new ProviderNotFoundException(messages.getMessage(
                    "ProviderManager.providerNotFound",
                    new Object[] { toTest.getName() },
                    "No AuthenticationProvider found for {0}"));
        }
​
        // 如果父类认证失败,会发布一个AbstractAuthenticationFailureEvent
        // 这一步检查,防止子类重复发布
        if (parentException == null) {
            prepareException(lastException, authentication);
        }
​
        throw lastException;
    }
​
    @SuppressWarnings("deprecation")
    private void prepareException(AuthenticationException ex, Authentication auth) {
        eventPublisher.publishAuthenticationFailure(ex, auth);
    }
​
    /**
     * 从source中复制用户的信息到dest
     *
     * Copies the authentication details from a source Authentication object to a
     * destination one, provided the latter does not already have one set.
     *
     * @param source source authentication
     * @param dest the destination authentication object
     */
    private void copyDetails(Authentication source, Authentication dest) {
        if ((dest instanceof AbstractAuthenticationToken) && (dest.getDetails() == null)) {
            AbstractAuthenticationToken token = (AbstractAuthenticationToken) dest;
​
            token.setDetails(source.getDetails());
        }
    }
​
    public List<AuthenticationProvider> getProviders() {
        return providers;
    }
​
    public void setMessageSource(MessageSource messageSource) {
        this.messages = new MessageSourceAccessor(messageSource);
    }
​
    public void setAuthenticationEventPublisher(
            AuthenticationEventPublisher eventPublisher) {
        Assert.notNull(eventPublisher, "AuthenticationEventPublisher cannot be null");
        this.eventPublisher = eventPublisher;
    }
​
    /**
     * 设置是否要在认证完成后,让Authentication调用自己的eraseCredentials方法来清除密码信息。
     *
     * If set to, a resulting {@code Authentication} which implements the
     * {@code CredentialsContainer} interface will have its
     * {@link CredentialsContainer#eraseCredentials() eraseCredentials} method called
     * before it is returned from the {@code authenticate()} method.
     *
     * @param eraseSecretData set to {@literal false} to retain the credentials data in
     * memory. Defaults to {@literal true}.
     */
    public void setEraseCredentialsAfterAuthentication(boolean eraseSecretData) {
        this.eraseCredentialsAfterAuthentication = eraseSecretData;
    }
​
    public boolean isEraseCredentialsAfterAuthentication() {
        return eraseCredentialsAfterAuthentication;
    }
​
    private static final class NullEventPublisher implements AuthenticationEventPublisher {
        public void publishAuthenticationFailure(AuthenticationException exception,
                Authentication authentication) {
        }
​
        public void publishAuthenticationSuccess(Authentication authentication) {
        }
    }
}

至此可以看到,ProviderManager的认证逻辑还是很简单清晰的,我们也可以比较清楚理解 AuthenticationManagerProviderManagerAuthenticationProvider 的关系了。

AuthenticationProvider

AuthenticationProvider 也是一个接口,用来完成具体的认证逻辑。不同的认证方式有不同的实现,Spring Security中提供了多种实现,包括 DaoAuthenticationProvider , AnonymousAuthenticationProviderLdapAuthenticationProvider 等。其中最简单的 DaoAuthenticationProvider 会在后面介绍,首先来看一下 AuthenticationProvider 的源码。同样的,保留了源码中的注释,感兴趣的同学可以细读,这里只做简单的介绍。

可以看到, AuthenticationProvider 中只有2个方法:

  • authenticate 完成具体的认证逻辑,如果认证失败,抛出AuthenticationException异常

  • supports 判断是否支持传入的Authentication认证信息

/**
 * Indicates a class can process a specific
 * {@link org.springframework.security.core.Authentication} implementation.
 *
 * @author Ben Alex
 */
public interface AuthenticationProvider {
    // ~ Methods
    // ========================================================================================================
​
    /**
     * Performs authentication with the same contract as
     * {@link org.springframework.security.authentication.AuthenticationManager#authenticate(Authentication)}
     * .
     *
     * @param authentication the authentication request object.
     *
     * @return a fully authenticated object including credentials. May return
     * <code>null</code> if the <code>AuthenticationProvider</code> is unable to support
     * authentication of the passed <code>Authentication</code> object. In such a case,
     * the next <code>AuthenticationProvider</code> that supports the presented
     * <code>Authentication</code> class will be tried.
     *
     * @throws AuthenticationException if authentication fails.
     */
    Authentication authenticate(Authentication authentication)
            throws AuthenticationException;
​
    /**
     * Returns <code>true</code> if this <Code>AuthenticationProvider</code> supports the
     * indicated <Code>Authentication</code> object.
     * <p>
     * Returning <code>true</code> does not guarantee an
     * <code>AuthenticationProvider</code> will be able to authenticate the presented
     * instance of the <code>Authentication</code> class. It simply indicates it can
     * support closer evaluation of it. An <code>AuthenticationProvider</code> can still
     * return <code>null</code> from the {@link #authenticate(Authentication)} method to
     * indicate another <code>AuthenticationProvider</code> should be tried.
     * </p>
     * <p>
     * Selection of an <code>AuthenticationProvider</code> capable of performing
     * authentication is conducted at runtime the <code>ProviderManager</code>.
     * </p>
     *
     * @param authentication
     *
     * @return <code>true</code> if the implementation can more closely evaluate the
     * <code>Authentication</code> class presented
     */
    boolean supports(Class<?> authentication);
}

DaoAuthenticationProvider

DaoAuthenticationProvider 是Spring Security提供的最简单的一个 AuthenticationProvider 的实现,也是框架中最早支持的。它使用 UserDetailsService 作为一个DAO来查询用户名、密码以及用户的权限 GrantedAuthority 。它认证用户的方式就是简单的比较 UsernamePasswordAuthenticationToken 中由用户提交的密码和通过 UserDetailsService 查询获得的密码是否一致。

下面我们来看一下 DaoAuthenticationProvider 的源码,对于源码的说明也写在了注释中。

DaoAuthenticationProvider 继承了 AbstractUserDetailsAuthenticationProvider ,而后者实现了 AuthenticationProvider 接口。

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
​
    /**
     * The plaintext password used to perform
     * PasswordEncoder#matches(CharSequence, String)}  on when the user is
     * not found to avoid SEC-2056.
     */
    private static final String USER_NOT_FOUND_PASSWORD = "userNotFoundPassword";
​
    private PasswordEncoder passwordEncoder;
​
    /**
     * The password used to perform
     * {@link PasswordEncoder#matches(CharSequence, String)} on when the user is
     * not found to avoid SEC-2056. This is necessary, because some
     * {@link PasswordEncoder} implementations will short circuit if the password is not
     * in a valid format.
     */
    private volatile String userNotFoundEncodedPassword;
​
    private UserDetailsService userDetailsService;
​
    private UserDetailsPasswordService userDetailsPasswordService;
​
    public DaoAuthenticationProvider() {
        setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder());
    }
​
    @SuppressWarnings("deprecation")
    protected void additionalAuthenticationChecks(UserDetails userDetails,
            UsernamePasswordAuthenticationToken authentication)
            throws AuthenticationException {
        
        // 用户未提交密码,抛出异常BadCredentialsException
        if (authentication.getCredentials() == null) {
            logger.debug("Authentication failed: no credentials provided");
​
            throw new BadCredentialsException(messages.getMessage(
                    "AbstractUserDetailsAuthenticationProvider.badCredentials",
                    "Bad credentials"));
        }
        
        // 从传入了Authentication对象中获取用户提交的密码
        String presentedPassword = authentication.getCredentials().toString();
        
        // 用passwordEncoder的matches方法,比较用户提交的密码和userDetails中查询到的正确密码。
        // 由于用户密码的存放一般都是hash后保密的,因此userDetails获取到的密码一般是一个hash值,而用户提交
        // 的是一个明文密码,因此需要对用户提交的密码进行同样的hash计算后再进行比较。
        if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
            logger.debug("Authentication failed: password does not match stored value");
​
            throw new BadCredentialsException(messages.getMessage(
                    "AbstractUserDetailsAuthenticationProvider.badCredentials",
                    "Bad credentials"));
        }
    }
​
    protected void doAfterPropertiesSet() throws Exception {
        Assert.notNull(this.userDetailsService, "A UserDetailsService must be set");
    }
​
    protected final UserDetails retrieveUser(String username,
            UsernamePasswordAuthenticationToken authentication)
            throws AuthenticationException {
        prepareTimingAttackProtection();
        try {
            UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
            if (loadedUser == null) {
                throw new InternalAuthenticationServiceException(
                        "UserDetailsService returned null, which is an interface contract violation");
            }
            return loadedUser;
        }
        catch (UsernameNotFoundException ex) {
            mitigateAgainstTimingAttack(authentication);
            throw ex;
        }
        catch (InternalAuthenticationServiceException ex) {
            throw ex;
        }
        catch (Exception ex) {
            throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
        }
    }
​
    @Override
    protected Authentication createSuccessAuthentication(Object principal,
            Authentication authentication, UserDetails user) {
        boolean upgradeEncoding = this.userDetailsPasswordService != null
                && this.passwordEncoder.upgradeEncoding(user.getPassword());
        if (upgradeEncoding) {
            String presentedPassword = authentication.getCredentials().toString();
            String newPassword = this.passwordEncoder.encode(presentedPassword);
            user = this.userDetailsPasswordService.updatePassword(user, newPassword);
        }
        return super.createSuccessAuthentication(principal, authentication, user);
    }
​
    private void prepareTimingAttackProtection() {
        if (this.userNotFoundEncodedPassword == null) {
            this.userNotFoundEncodedPassword = this.passwordEncoder.encode(USER_NOT_FOUND_PASSWORD);
        }
    }
​
    private void mitigateAgainstTimingAttack(UsernamePasswordAuthenticationToken authentication) {
        if (authentication.getCredentials() != null) {
            String presentedPassword = authentication.getCredentials().toString();
            this.passwordEncoder.matches(presentedPassword, this.userNotFoundEncodedPassword);
        }
    }
​
    /**
     * Sets the PasswordEncoder instance to be used to encode and validate passwords. If
     * not set, the password will be compared using {@link PasswordEncoderFactories#createDelegatingPasswordEncoder()}
     *
     * @param passwordEncoder must be an instance of one of the {@code PasswordEncoder}
     * types.
     */
    public void setPasswordEncoder(PasswordEncoder passwordEncoder) {
        Assert.notNull(passwordEncoder, "passwordEncoder cannot be null");
        this.passwordEncoder = passwordEncoder;
        this.userNotFoundEncodedPassword = null;
    }
​
    protected PasswordEncoder getPasswordEncoder() {
        return passwordEncoder;
    }
​
    public void setUserDetailsService(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }
​
    protected UserDetailsService getUserDetailsService() {
        return userDetailsService;
    }
​
    public void setUserDetailsPasswordService(
            UserDetailsPasswordService userDetailsPasswordService) {
        this.userDetailsPasswordService = userDetailsPasswordService;
    }
}

AbstractUserDetailsAuthenticationProvider

可以看到 DaoAuthenticationProvider 继承自 AbstractUserDetailsAuthenticationProvider , 而一个provider最核心的 authenticate 方法,便写在了 AbstractUserDetailsAuthenticationProvider 中,下面我们只关注一下 authenticate 这个方法的源码。

public Authentication authenticate(Authentication authentication)
            throws AuthenticationException {
        Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
                () -> messages.getMessage(
                        "AbstractUserDetailsAuthenticationProvider.onlySupports",
                        "Only UsernamePasswordAuthenticationToken is supported"));
​
        // 从传入的Authentication对象中获取用户名
        String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
                : authentication.getName();
        
        // 根据用户名,从缓存中获取用户的UserDetails
        boolean cacheWasUsed = true;
        UserDetails user = this.userCache.getUserFromCache(username);
        
        if (user == null) {
            cacheWasUsed = false;
            // 如果从缓存中没有获取到用户,则通过方法retrieveUser来获取用户信息
            // retrieve方法为一个抽象方法,不同的子类中有不同的实现,而在子类中,一般又会通过UserDetailService来获取用户信息,返回UserDetails
            try {
                user = retrieveUser(username,
                        (UsernamePasswordAuthenticationToken) authentication);
            }
            catch (UsernameNotFoundException notFound) {
                logger.debug("User '" + username + "' not found");
​
                if (hideUserNotFoundExceptions) {
                    throw new BadCredentialsException(messages.getMessage(
                            "AbstractUserDetailsAuthenticationProvider.badCredentials",
                            "Bad credentials"));
                }
                else {
                    throw notFound;
                }
            }
​
            Assert.notNull(user,
                    "retrieveUser returned null - a violation of the interface contract");
        }
​
        try {
            preAuthenticationChecks.check(user);
            // additionalAuthenticationChecks为具体的认证逻辑,是一个抽象方法,在子类中实现。
            // 比如前文中DaoAuthenticationProvider中,便是比较用户提交的密码和UserDetails中的密码
            additionalAuthenticationChecks(user,
                    (UsernamePasswordAuthenticationToken) authentication);
        }
        catch (AuthenticationException exception) {
            if (cacheWasUsed) {
                // There was a problem, so try again after checking
                // we're using latest data (i.e. not from the cache)
                cacheWasUsed = false;
                user = retrieveUser(username,
                        (UsernamePasswordAuthenticationToken) authentication);
                preAuthenticationChecks.check(user);
                additionalAuthenticationChecks(user,
                        (UsernamePasswordAuthenticationToken) authentication);
            }
            else {
                throw exception;
            }
        }
​
        postAuthenticationChecks.check(user);
​
        if (!cacheWasUsed) {
            this.userCache.putUserInCache(user);
        }
​
        Object principalToReturn = user;
​
        if (forcePrincipalAsString) {
            principalToReturn = user.getUsername();
        }
​
        return createSuccessAuthentication(principalToReturn, authentication, user);
    }

可以看到在 DaoAuthenticationProvider 中还用到 UserDetailsService 来查询用户的密码权限信息,并包装为 UserDetails 返回,然后与用户提交的用户名密码信息进行比较来完成认证。 UserDetailsServiceUserDetails 在不同的provider中都会被用到,关于这两个接口的说明,在下一篇文章中介绍。

总结

最后我们总结一下这几个接口和类

名称 类型 说明
AuthenticationManager 接口 完成认证
ProviderManager 实现AuthenticationManager接口,将认证任务委托给不同的AuthenticationProvider
AuthenticationProvider 接口 具体认证逻辑的接口
AbstractUserDetailsAuthenticationProvider 抽象类 实现了AuthenticationProvider接口,获取用户信息并完成认证的抽象类
DaoAuthenticationProvider 继承AbstractUserDetailsAuthenticationProvider,实现具体的认证逻辑

这些类和接口之间的关系,大致可以用下图进行表示

Spring Security 02 — Basic Introduction Part II


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

编译器设计

编译器设计

Keith Cooper、Linda Torczon / 郭旭 / 人民邮电出版社 / 2012-12 / 99.00元

深入剖析现代编译器运用的算法和技术 强调代码优化和代码生成 体现编译原理教学的最新理念 本书旨在介绍编译器构造法中的艺术和科学。书中深入分析现代编译器后端所用的算法和技术,重点讨论代码优化和代码生成,详细介绍了用几个编程语言编写的示例等。 Keith D. Cooper 莱斯大学计算机科学系计算工程专业Doerr特聘教授,曾任该系系主任。Cooper博士的研究课题涵盖过程间......一起来看看 《编译器设计》 这本书的介绍吧!

JSON 在线解析
JSON 在线解析

在线 JSON 格式化工具

html转js在线工具
html转js在线工具

html转js在线工具

RGB HSV 转换
RGB HSV 转换

RGB HSV 互转工具