zoukankan      html  css  js  c++  java
  • Spring Securtiy 认证流程(源码分析)

    当用 Spring Security 框架进行认证时,你可能会遇到这样的问题:

    你输入的用户名或密码不管是空还是错误,它的错误信息都是 Bad credentials。

    那么如果你想根据不同的情况给出相应的错误提示该怎么办呢?

    这个时候我们只有了解 Spring Securiy 认证的流程才能知道如何修改代码。

    好啦,来看下面的例子,大部分人的 WebSecurityConfig 的 configure 代码都类似于下:

        @Override
        protected void configure(HttpSecurity http) throws Exception {
            // TODO Auto-generated method stub
            http
            .authorizeRequests()
            .anyRequest().permitAll()
            .and()
            .formLogin().loginPage("/signin")
            .usernameParameter("username")
            .passwordParameter("password")
            .loginProcessingUrl("/signin")
            .and()
            .csrf().disable();
        }

    相信以上代码大家都知道什么意思:任何请求信息都允许,也就是不需要身份认证。

    登录页面请求为 /signin,用户名和密码参数的name属性分别是 username,password。登录页面 form 的 action 请求为 /signin。

    当然这个 action 不必和登录页面请求一样。最后的那个是禁止跨站请求伪造。

    这段代码和登录认证联系较大的应该是从 loginPage() 到 loginProcessingUrl() 里的方法。

    咱先从 loginPage 看起,鼠标左键拖动覆盖 loginPage,然后右键 Open Declaration 就进入到了 FormLoginConfigurer 类。

    这个类里值得注意的方法有两个:构造方法和 loginPage 方法。

        public FormLoginConfigurer() {
            super(new UsernamePasswordAuthenticationFilter(), null);
            usernameParameter("username");
            passwordParameter("password");
        }
    
        public FormLoginConfigurer<H> loginPage(String loginPage) {
            return super.loginPage(loginPage);
        }

    构造方法中使用了一个用户名密码认证过滤器类,这一看就和认证有关系。

    loginPage 方法大家可以自行按照这个步骤查看,现在直接看 UsernamePasswordAuthenticationFilter 类。

        public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
        public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
        private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;
        private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;
        private boolean postOnly = true;
    
       public UsernamePasswordAuthenticationFilter() {
            super(new AntPathRequestMatcher("/login", "POST"));
        }
    
       public Authentication attemptAuthentication(HttpServletRequest request,
                HttpServletResponse response) throws AuthenticationException {
            if (postOnly && !request.getMethod().equals("POST")) {
                throw new AuthenticationServiceException(
                        "Authentication method not supported: " + request.getMethod());
            }
            String username = obtainUsername(request);
            String password = obtainPassword(request);
            if (username == null) {
                username = "";
            }
            if (password == null) {
                password = "";
            }
            username = username.trim();
            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
            username, password);
        
    setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); }

     这只是其中一部分代码,其他的可以自己看。该类中定义的两个字符串和构造方法定义了默认的登录方式。

    登录 action 请求为以 POST 方式的 /login,用户名及密码分别以 username,password 属性值获取。

    该类的父类的父类 GenericFilterBean 实现了 InitializingBean 接口,也就是会初始化为一个 Bean。

    当看到 attemptAuthentication 时,就知道他是认证的方法啦。

    这里咱直接看到 new UsernamePasswordAuthenticationToken(username, password);

        public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
            super(null);
            this.principal = principal;
            this.credentials = credentials;
            setAuthenticated(false);
        }

    从这里可以知道,它把用户名和密码分别存在了 principal,credentials 里。

    现在我们只需要记住登录信息存在了 authRequest 里。现在来看下setDetails,虽然我不感兴趣。

        protected void setDetails(HttpServletRequest request,
                UsernamePasswordAuthenticationToken authRequest) {
            authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
        }

    它调用了一个 buildDetails 方法,实际上是调用的:(追根溯源可以看到)

        
    /**
      * Records the remote address and will also set the session Id if a session already
      * exists (it won't create one).
      *
      * @param request that the authentication request was received from
      */
    public WebAuthenticationDetails(HttpServletRequest request) { this.remoteAddress = request.getRemoteAddr(); HttpSession session = request.getSession(false); this.sessionId = (session != null) ? session.getId() : null; }

    从源码注释可以看到,它是记录远程地址并且会设置一个会话 ID,这里我们不管它了。

    直接看这一句:return this.getAuthenticationManager().authenticate(authRequest);

    它调用的是一个实现了 AuthenticationManager 接口的类的 authenticate 方法。

    从源码中我们找不到它用的是哪个实现类,网上说是 ProviderManager 类,我们来看一下该类。

    public class ProviderManager implements AuthenticationManager, MessageSourceAware,
            InitializingBean {
      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();
    
            for (AuthenticationProvider provider : getProviders()) {
                if (!provider.supports(toTest)) {
                    continue;
                }
    
                if (debug) {
                    logger.debug("Authentication attempt using "
                            + provider.getClass().getName());
                }
    
                try {
                    result = provider.authenticate(authentication);
    
                    if (result != null) {
                        copyDetails(authentication, result);
                        break;
                    }
                }
                catch (AccountStatusException e) {
                    prepareException(e, authentication);
                    // SEC-546: Avoid polling additional providers if auth failure is due to
                    // invalid account status
                    throw e;
                }
                catch (InternalAuthenticationServiceException e) {
                    prepareException(e, authentication);
                    throw e;
                }
                catch (AuthenticationException e) {
                    lastException = e;
                }
            }
    
            if (result == null && parent != null) {
                // Allow the parent to try.
                try {
                    result = parentResult = parent.authenticate(authentication);
                }
                catch (ProviderNotFoundException e) {
                    // ignore as we will throw below if no other exception occurred prior to
                    // calling parent and the parent
                    // may throw ProviderNotFound even though a provider in the child already
                    // handled the request
                }
                catch (AuthenticationException e) {
                    lastException = parentException = e;
                }
            }
    
            if (result != null) {
                if (eraseCredentialsAfterAuthentication
                        && (result instanceof CredentialsContainer)) {
                    // Authentication is complete. Remove credentials and other secret data
                    // from authentication
                    ((CredentialsContainer) result).eraseCredentials();
                }
    
                // If the parent AuthenticationManager was attempted and successful than it will publish an AuthenticationSuccessEvent
                // This check prevents a duplicate AuthenticationSuccessEvent if the parent AuthenticationManager already published it
                if (parentResult == null) {
                    eventPublisher.publishAuthenticationSuccess(result);
                }
                return result;
            }
    
            // Parent was null, or didn't authenticate (or throw an exception).
    
            if (lastException == null) {
                lastException = new ProviderNotFoundException(messages.getMessage(
                        "ProviderManager.providerNotFound",
                        new Object[] { toTest.getName() },
                        "No AuthenticationProvider found for {0}"));
            }
    
            // If the parent AuthenticationManager was attempted and failed than it will publish an AbstractAuthenticationFailureEvent
            // This check prevents a duplicate AbstractAuthenticationFailureEvent if the parent AuthenticationManager already published it
            if (parentException == null) {
                prepareException(lastException, authentication);
            }
    
            throw lastException;
        }
    }

    这里我只给出该类的声明和 authenticate 方法,从类的声明可以看出来它也会初始化为一个 Bean,咱找不到很正常对吧。

    authenticate 方法会遍历所有的 AuthenticationProvider ,然后调用 provider 的 authenticate 方法。

    如果认证结果不为空的话将会保存到 result 中,并且擦除认证信息再返回 result。

    为空的话一般是没有提供 AuthenticationProvider,会报 ProviderNotFoundException 错误。

    现在我们来看下 provider 的 authenticate 方法。

        @Bean
        public AuthenticationProvider authenticationProvider() {
            DaoAuthenticationProvider provider = new CustomAuthenticationProvider();
            provider.setMessageSource(messageSource);
            provider.setUserDetailsService(userService);
            provider.setPasswordEncoder(new BCryptPasswordEncoder());
            return provider;
        }

    这个是我写的一个 AuthenticationProvider,只不过我重写了一个类继承了 DaoAuthenticationProvider。

    这里我们来看 DaoAuthenticationProvider 类:(这个类里面并没有发现  authenticate 方法,那先从它的父类找)

    父类是 AbstractUserDetailsAuthenticationProvider,它也实现了 InitializingBean 接口,也是初始化为一个 Bean。

    public Authentication authenticate(Authentication authentication)
                throws AuthenticationException {
            Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
                    () -> messages.getMessage(
                            "AbstractUserDetailsAuthenticationProvider.onlySupports",
                            "Only UsernamePasswordAuthenticationToken is supported"));
    
            // Determine username
            String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
                    : authentication.getName();
    
            boolean cacheWasUsed = true;
            UserDetails user = this.userCache.getUserFromCache(username);
    
            if (user == null) {
                cacheWasUsed = false;
    
                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(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);
        }

     在这段代码中可以知道:如果 authentication.getPrincipal() 为空的话,username 将会为 NONE_PROVIDED。

    不为空的话将会得到 authentication.getPrincipal(),也就是用户名,只是这种类型不是 String 类型,但可以强制转换。

    代码中是 authentication.getName(),这种和上面基本一样,只不过该类型是 String 类型的。

    然后定义一个 user,先尝试从缓存中获取 user,没获取到的话就通过 retrieveUser 获取。

    该类中 retrieveUser 是一个抽象方法,我们现在来看 DaoAuthenticationProvider 类里的方法。

    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);
            }
        }

    从代码中可以看到是通过我们之前写的 UserDetailsService 方法获取用户。

    接下来我们看后面的代码,这部分异常代码我们等会再看。

    try {
                preAuthenticationChecks.check(user);
                additionalAuthenticationChecks(user,
                        (UsernamePasswordAuthenticationToken) authentication);
            }

    这两句代码是对用户进行检查的,第一行代码调用的其实是这部分的:

    private class DefaultPreAuthenticationChecks implements UserDetailsChecker {
            public void check(UserDetails user) {
                if (!user.isAccountNonLocked()) {
                    logger.debug("User account is locked");
    
                    throw new LockedException(messages.getMessage(
                            "AbstractUserDetailsAuthenticationProvider.locked",
                            "User account is locked"));
                }
    
                if (!user.isEnabled()) {
                    logger.debug("User account is disabled");
    
                    throw new DisabledException(messages.getMessage(
                            "AbstractUserDetailsAuthenticationProvider.disabled",
                            "User is disabled"));
                }
    
                if (!user.isAccountNonExpired()) {
                    logger.debug("User account is expired");
    
                    throw new AccountExpiredException(messages.getMessage(
                            "AbstractUserDetailsAuthenticationProvider.expired",
                            "User account has expired"));
                }
            }
        }

    可以看到并不是检查密码的,只是对用户状态进行检查。那么我们不管它了,看下一行代码:

    @SuppressWarnings("deprecation")
        protected void additionalAuthenticationChecks(UserDetails userDetails,
                UsernamePasswordAuthenticationToken authentication)
                throws AuthenticationException {
            if (authentication.getCredentials() == null) {
                logger.debug("Authentication failed: no credentials provided");
    
                throw new BadCredentialsException(messages.getMessage(
                        "AbstractUserDetailsAuthenticationProvider.badCredentials",
                        "Bad credentials"));
            }
    
            String presentedPassword = authentication.getCredentials().toString();
    
            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"));
            }
        }

    这里有个获取密码的操作:authentication.getCredentials()。

    然后如果密码不为空的话就通过 passwordEncoder.matches(presentedPassword, userDetails.getPassword() 检查是否匹配。

    如果匹配成功的话,嗯,这部分结束了,我们回到 AbstractUserDetailsAuthenticationProvider 类里的 authenticate 方法。

    return createSuccessAuthentication(principalToReturn, authentication, user);

    它会返回一个创建成功认证方法的返回值。这里我们就不管了。

    现在我们先回到AbstractUserDetailsAuthenticationProvider 类的错误处理上:

    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;
                    }
                }

    这个是用户找不到引起的错误,我们看下 messages.getMessage():

        public String getMessage(String code, String defaultMessage) {
            String msg = this.messageSource.getMessage(code, null, defaultMessage, getDefaultLocale());
            return (msg != null ? msg : "");
        }

    再来看下这个里面的 getMessage():

    它是一个接口类里的方法:根据 code 返回 messageSource 里的字符串,如果不存在这个 code,就返回 defaultMessage。

    既然是个接口类,那我们看下它的实现类,回到 messageSource,查看一下它:

    public class SpringSecurityMessageSource extends ResourceBundleMessageSource {
        // ~ Constructors
        // ===================================================================================================
    
        public SpringSecurityMessageSource() {
            setBasename("org.springframework.security.messages");
        }
    
        // ~ Methods
        // ========================================================================================================
    
        public static MessageSourceAccessor getAccessor() {
            return new MessageSourceAccessor(new SpringSecurityMessageSource());
        }
    }

    原来是从这个路径里找数据源。

    其他的错误处理也是一样,这里就省略了。那我们如何获取错误信息呢?

        @Override
        protected void configure(HttpSecurity http) throws Exception {
            // TODO Auto-generated method stub
            http
            .authorizeRequests()
            .anyRequest().permitAll()
            .and()
            .formLogin().loginPage("/signin")
            .usernameParameter("username")
            .passwordParameter("password")
            .loginProcessingUrl("/signin")
            .failureHandler(authenticationFailureHandler)
            .and()
            .csrf().disable();

    看到那个 failureHandler 没,这个是登录失败处理器,这里加上只是看一下里面源码:AbstractAuthenticationFilterConfigurer

        /**
         * Specifies the {@link AuthenticationFailureHandler} to use when authentication
         * fails. The default is redirecting to "/login?error" using
         * {@link SimpleUrlAuthenticationFailureHandler}
         *
         * @param authenticationFailureHandler the {@link AuthenticationFailureHandler} to use
         * when authentication fails.
         * @return the {@link FormLoginConfigurer} for additional customization
         */
        public final T failureHandler(
                AuthenticationFailureHandler authenticationFailureHandler) {
            this.failureUrl = null;
            this.failureHandler = authenticationFailureHandler;
            return getSelf();
        }

    从注释中可以看出默认的失败处理器是 SimpleUrlAuthenticationFailureHandler:

        public void onAuthenticationFailure(HttpServletRequest request,
                HttpServletResponse response, AuthenticationException exception)
                throws IOException, ServletException {
    
            if (defaultFailureUrl == null) {
                logger.debug("No failure URL set, sending 401 Unauthorized error");
    
                response.sendError(HttpStatus.UNAUTHORIZED.value(),
                    HttpStatus.UNAUTHORIZED.getReasonPhrase());
            }
            else {
                saveException(request, exception);
    
                if (forwardToDestination) {
                    logger.debug("Forwarding to " + defaultFailureUrl);
    
                    request.getRequestDispatcher(defaultFailureUrl)
                            .forward(request, response);
                }
                else {
                    logger.debug("Redirecting to " + defaultFailureUrl);
                    redirectStrategy.sendRedirect(request, response, defaultFailureUrl);
                }
            }
        }

    因为默认的 defaultFailureUrl 为 /login?error,从 AbstractAuthenticationFilterConfigurer 类里可以看出来。

    登录失败后,会调用 saveException(request, exception); 保存错误信息。

    protected final void saveException(HttpServletRequest request,
                AuthenticationException exception) {
            if (forwardToDestination) {
                request.setAttribute(WebAttributes.AUTHENTICATION_EXCEPTION, exception);
            }
            else {
                HttpSession session = request.getSession(false);
    
                if (session != null || allowSessionCreation) {
                    request.getSession().setAttribute(WebAttributes.AUTHENTICATION_EXCEPTION,
                            exception);
                }
            }
        }

    由于该类中 forwardToDestination 为 false,它将执行 else 里的语句。

    将错误信息保存到会话的 WebAttributes.AUTHENTICATION_EXCEPTION 属性中:

    public static final String AUTHENTICATION_EXCEPTION = "SPRING_SECURITY_LAST_EXCEPTION";

    所有我们可以通过会话的这个属性来获取错误信息。(thymeleaf)

    (注意:signin.html 不能放在 static 目录下,不然获取不到错误信息。)

    <p th:if="${param.error}" th:text="${session?.SPRING_SECURITY_LAST_EXCEPTION?.message}" ></p>

    好啦,都介绍完了,可以看下我的 CustomAuthenticationProvider:

    package security.config;
    
    import org.springframework.security.authentication.BadCredentialsException;
    import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
    import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
    import org.springframework.security.core.Authentication;
    import org.springframework.security.core.AuthenticationException;
    import org.springframework.security.core.userdetails.UserDetails;
    import org.springframework.security.core.userdetails.UsernameNotFoundException;
    import org.springframework.util.Assert;
    
    public class CustomAuthenticationProvider extends DaoAuthenticationProvider {
        
        @Override
        protected void additionalAuthenticationChecks(UserDetails userDetails,
                UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
            // TODO Auto-generated method stub
    
            String presentedPassword = authentication.getCredentials().toString();
            if (!getPasswordEncoder().matches(presentedPassword, userDetails.getPassword())) {
                logger.debug("Authentication failed: password does not match stored value");
    
                throw new BadCredentialsException(messages.getMessage(
                        "UNameOrPwdIsError","Username or Password is not correct"));
            }
        }
    
        @Override
        public Authentication authenticate(Authentication authentication) throws AuthenticationException {
            // TODO Auto-generated method stub
            Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
                    () -> messages.getMessage(
                            "AbstractUserDetailsAuthenticationProvider.onlySupports",
                            "Only UsernamePasswordAuthenticationToken is supported"));
    
            if("".equals(authentication.getPrincipal())) {
                throw new BadCredentialsException(messages.getMessage(
                        "UsernameIsNull","Username cannot be empty"));
            }
            if("".equals(authentication.getCredentials())) {
                throw new BadCredentialsException(messages.getMessage(
                        "PasswordIsNull","Password cannot be empty"));
            }
            
            String username = (String) authentication.getPrincipal();
            boolean cacheWasUsed = true;
            UserDetails user = this.getUserCache().getUserFromCache(username);
            if (user == null) {
                cacheWasUsed = false;
                try {
                    user = retrieveUser(username,
                            (UsernamePasswordAuthenticationToken) authentication);
                }
                catch (UsernameNotFoundException notFound) {
                    logger.debug("User '" + username + "' not found");
    
                    if (hideUserNotFoundExceptions) {
                        throw new BadCredentialsException(messages.getMessage(
                                "UNameOrPwdIsError","Username or Password is not correct"));
                    }
                    else {
                        throw notFound;
                    }
                }
                Assert.notNull(user,
                        "retrieveUser returned null - a violation of the interface contract");
            }
            try {
                getPreAuthenticationChecks().check(user);
                additionalAuthenticationChecks(user,
                        (UsernamePasswordAuthenticationToken) authentication);
            }
            catch (AuthenticationException exception) {
                if (cacheWasUsed) {
                    cacheWasUsed = false;
                    user = retrieveUser(username,
                            (UsernamePasswordAuthenticationToken) authentication);
                    getPreAuthenticationChecks().check(user);
                    additionalAuthenticationChecks(user,
                            (UsernamePasswordAuthenticationToken) authentication);
                }
                else {
                    throw exception;
                }
            }
    
            getPostAuthenticationChecks().check(user);
    
            if (!cacheWasUsed) {
                this.getUserCache().putUserInCache(user);
            }
    
            Object principalToReturn = user;
    
            if (isForcePrincipalAsString()) {
                principalToReturn = user.getUsername();
            }
    
            return createSuccessAuthentication(principalToReturn, authentication, user);
        }
        
    
    }

    这里值得注意的是 "".equals(authentication.getPrincipal()),"".equals(authentication.getCredentials())

    因为如果按照那个 AbstractUserDetailsAuthenticationProvider 类来写的话,发现这一步永不为 null。

    我通过加入代码 System.out.println(username);  才知道的,应该是个坑吧。

    项目代码可供大家参考:

    链接:https://pan.baidu.com/s/13fc6P9NV49aRRBctr3MjNQ
    提取码:4qgu

  • 相关阅读:
    es5预览本地文件、es6练习代码演示案例
    Java实现 LeetCode 838 推多米诺(暴力模拟)
    Java实现 LeetCode 838 推多米诺(暴力模拟)
    Java实现 LeetCode 838 推多米诺(暴力模拟)
    Java实现 LeetCode 837 新21点(DP)
    Java实现 LeetCode 837 新21点(DP)
    Java实现 LeetCode 837 新21点(DP)
    Java实现 LeetCode 836 矩形重叠(暴力)
    Subversion under Linux [Reprint]
    Subversion how[Reprint]
  • 原文地址:https://www.cnblogs.com/M-Anonymous/p/12003968.html
Copyright © 2011-2022 走看看