Spring Security

参考文章

spring security 极客学院
spring security 博客园
Spring security 基本流程

Java配置的方式
spring security 大佬博客
spring security CSDN

基于XML配置

<beans:beans xmlns="http://www.springframework.org/schema/security"
             xmlns:beans="http://www.springframework.org/schema/beans"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-4.2.xsd
                                 http://www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security-4.0.xsd">


    <http security="none" pattern="/resources/**"/>
    <http security="none" pattern="/lib/**"/>

    <http access-decision-manager-ref="accessDecisionManager">
        <csrf request-matcher-ref="csrfSecurityRequestMatcher"  />
        <!--<csrf disabled="true"/>-->
        <intercept-url pattern="/login" access="permitAll" />
        <intercept-url pattern="/timeout" access="permitAll" />
        <intercept-url pattern="/login.html" access="permitAll" />
        <intercept-url pattern="/verifiCode" access="permitAll" />
        <intercept-url pattern="/common/**" access="permitAll" />
        <intercept-url pattern="/websocket/**" access="permitAll" />
        <intercept-url pattern="/**" access="hasRole('ROLE_USER')" />
        <access-denied-handler error-page="/access-denied"/>
        <session-management invalid-session-url="/timeout"/>

        <form-login login-page='/login' authentication-success-handler-ref="successHandler"
                    authentication-failure-handler-ref="loginFailureHandler"/>
                    <!--authentication-failure-url="/login?error=true"/>-->
        <!-- 验证码拦截器 -->
        <custom-filter ref="captchaVerifierFilter" before="FORM_LOGIN_FILTER"/>

        <logout logout-url="/logout" success-handler-ref="logoutHandler"/>

        <headers defaults-disabled="true">
            <cache-control/>
        </headers>
    </http>


    <!-- 认证管理器,确定用户,角色及相应的权限 -->
    <beans:bean id="accessDecisionManager" class="org.springframework.security.access.vote.UnanimousBased">
        <!-- 投票器 -->
        <beans:constructor-arg>
            <beans:list>

                <beans:bean class="com.hand.hap.security.CustomWebExpressionVoter"/>
                <beans:bean class="org.springframework.security.access.vote.RoleVoter"/>
                <beans:bean class="org.springframework.security.access.vote.AuthenticatedVoter"/>
                <beans:bean class="com.hand.hap.security.PermissionVoter"/>
            </beans:list>
        </beans:constructor-arg>
    </beans:bean>

    <!--<beans:import resource="standardSecurity-LDAP.xml"/>-->
    <authentication-manager>
        <!--Ldap验证-->
        <!-- <authentication-provider ref="ldapAuthProvider" />-->
        <!--标准登录验证-->
        <authentication-provider user-service-ref="customUserDetailsService">
            <password-encoder ref="passwordManager"/>
        </authentication-provider>
    </authentication-manager>

    <beans:bean id="captchaVerifierFilter" class="com.hand.hap.security.CaptchaVerifierFilter">
        <beans:property name="captchaField" value="verifiCode"/>
    </beans:bean>

    <beans:bean id="successHandler" class="com.hand.hap.security.CustomAuthenticationSuccessHandler">
       <!-- <beans:property name="defaultTargetUrl" value="/index"/>-->
    </beans:bean>
    
    <beans:bean id="loginFailureHandler" class="com.hand.hap.security.LoginFailureHandler"/>


    <beans:bean id="logoutHandler" class="com.hand.hap.security.CustomLogoutSuccessHandler"></beans:bean>

    <beans:bean id="csrfSecurityRequestMatcher" class="com.hand.hap.security.CsrfSecurityRequestMatcher">
           <beans:property name="excludeUrls">
                <beans:list>
                   <beans:value>/login</beans:value>
                   <beans:value>/websocket/**</beans:value>
                    <beans:value>/ureport/**</beans:value>
              </beans:list>
            </beans:property>
    </beans:bean>

</beans:beans>

基于Java配置

package com.hand.sxy.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

/**
 * @author spilledyear
 * @date 2018/4/24 13:19
 */
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers("/", "/login").permitAll()
                .anyRequest().authenticated()
                .and()
            .formLogin()
                .loginPage("/login")
                .permitAll()
                .and()
            .logout()
                .permitAll();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth
            .inMemoryAuthentication()
                .withUser("admin").password("admin").roles("USER");
    }
}

默认验证

当我们项目里添加spring security依赖它就已经起作用了,启动项目访问时,会出现弹出框。spring security默认采用basic模式认证。浏览器发送http报文请求一个受保护的资源,浏览器会弹出对话框让输入用户名和密码。并以用
户名:密码的形式base64加密,加入Http报文头部的Authorization(默认用户名为user,密码则是会在启动程序时后
台console里输出,每次都不一样)。后台获取Http报文头部相关认证信息,认证成功返回相应内容,失败则继续认证。

基本概念

  • AuthenticationManager: 身份验证的主要策略设置接口。
  • ProviderManager: AuthenticationManager接口的最常用实现类。
  • AuthenticationProvider: 是一个接口,ProviderManager委托列表中AuthenticationProvider处理认证工作。
  • Authentication: 认证用户信息主体。
  • GrantedAuthority: 用户主体的权限。
  • UserDetails: 【接口】,通过自定义实现封装用户的基本必要信息。
  • UserDetailsService: 【接口】,自定义的实现类通过loadUserByUsername方法返回一个UserDetails对象。
  • SecurityContextHolder: 提供访问SecurityContext。
  • SecurityContext: 保存Authentication,和一些其它的信息。

ProviderManager把工作委托给AuthenticationProvider集合,对所有AuthenticationProvider进行循环,直到运行返回一个完整的Authentication,不符合条件或者不能认证当前Authentication,返回AuthenticationException异常或者null。

核心过滤器

// 每个日志前面自动加上这个
2018-05-16 13:43:56.700 DEBUG 14448 --- [nio-8081-exec-1] o.s.security.web.FilterChainProxy

: /error at position 1 of 12 in additional filter chain; firing Filter: 'WebAsyncManagerIntegrationFilter'

: /error at position 2 of 12 in additional filter chain; firing Filter: 'SecurityContextPersistenceFilter'

: /error at position 3 of 12 in additional filter chain; firing Filter: 'HeaderWriterFilter'

: /error at position 4 of 12 in additional filter chain; firing Filter: 'LogoutFilter'

: /error at position 5 of 12 in additional filter chain; firing Filter: 'JwtAuthorizationTokenFilter'

: /error at position 6 of 12 in additional filter chain; firing Filter: 'UsernamePasswordAuthenticationFilter'

: /error at position 7 of 12 in additional filter chain; firing Filter: 'RequestCacheAwareFilter'

: /error at position 8 of 12 in additional filter chain; firing Filter: 'SecurityContextHolderAwareRequestFilter'

: /error at position 9 of 12 in additional filter chain; firing Filter: 'AnonymousAuthenticationFilter'
o.s.s.w.a.AnonymousAuthenticationFilter  : Populated SecurityContextHolder with anonymous token: 'org.springframework.security.authentication.AnonymousAuthenticationToken@1dcfc3ac: Principal: anonymousUser; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@b364: RemoteIpAddress: 0:0:0:0:0:0:0:1; SessionId: null; Granted Authorities: ROLE_ANONYMOUS'

: /error at position 10 of 12 in additional filter chain; firing Filter: 'SessionManagementFilter'

: /error at position 11 of 12 in additional filter chain; firing Filter: 'ExceptionTranslationFilter'

: /error at position 12 of 12 in additional filter chain; firing Filter: 'FilterSecurityInterceptor'

以上内容是我格式化之后的日志,代码的顺序不变。可以看到一共有12个过滤器,其中 第五个 过滤器 JwtAuthorizationTokenFilter 是我自定义的,其余的全是Spring Security自带的。也就是说,Spring Security中默认有 11 个过滤器。

  • SecurityContextPersistenceFilter 两个主要职责:请求来临时,创建SecurityContext安全上下文信息,请求结束时清空SecurityContextHolder。
  • HeaderWriterFilter (文档中并未介绍,非核心过滤器) 用来给http响应添加一些Header,比如X-Frame-Options, X-XSS-Protection*,X-Content-Type-Options.
  • CsrfFilter 在spring4这个版本中被默认开启的一个过滤器,用于防止csrf攻击,了解前后端分离的人一定不会对这个攻击方式感到陌生,前后端使用json交互需要注意的一个问题。
  • LogoutFilter 顾名思义,处理注销的过滤器
  • UsernamePasswordAuthenticationFilter 表单提交了username和password,被封装成token进行一系列的认证,便是主要通过这个过滤器完成的,在表单认证的方法中,这是最最关键的过滤器。
  • RequestCacheAwareFilter (文档中并未介绍,非核心过滤器) 内部维护了一个RequestCache,用于缓存request请求
  • SecurityContextHolderAwareRequestFilter 此过滤器对ServletRequest进行了一次包装,使得request具有更加丰富的API
  • AnonymousAuthenticationFilter 匿名身份过滤器,这个过滤器个人认为很重要,需要将它与UsernamePasswordAuthenticationFilter 放在一起比较理解,spring security为了兼容未登录的访问,也走了一套认证流程,只不过是一个匿名的身份。
  • SessionManagementFilter 和session相关的过滤器,内部维护了一个SessionAuthenticationStrategy,两者组合使用,常用来防止session-fixation protection attack,以及限制同一用户开启多个会话的数量
  • ExceptionTranslationFilter 直译成异常翻译过滤器,还是比较形象的,这个过滤器本身不处理异常,而是将认证过程中出现的异常交给内部维护的一些类去处理,具体是那些类下面详细介绍
  • FilterSecurityInterceptor 这个过滤器决定了访问特定路径应该具备的权限,访问的用户的角色,权限是什么?访问的路径需要什么样的角色和权限?这些判断和处理都是由该类进行的。其实这个真的非常非常重要,前面的东西都是和用户认证相关,而这个是控制哪些资源是受限的,这些受限的资源需要什么权限,需要什么角色。

其中加粗的过滤器可以被认为是Spring Security的核心过滤器。在日志中未发现 CsrfFilter ,是因为我在代码中把 csrf保护关闭了。

FilterSecurityInterceptor的工作流程可以理解如下:FilterSecurityInterceptor从SecurityContextHolder中获取Authentication对象,然后比对用户拥有的权限和资源所需的权限。前者可以通过Authentication对象直接获得,而后者则需要引入两个类:SecurityMetadataSource,AccessDecisionManager。

认证流程

AbstractAuthenticationProcessingFilter 用于拦截认证请求,它是基于浏览器和 HTTP 认证请求的处理器,可以理解为它就是 Spring Security 认证流程的入口。
整个认证流程如下:
AbstractAuthenticationProcessingFilter 收集用于认证的用户身份信息(通常是用户名和密码),并基于这些信息构造一个 Authentication 请求对象,AbstractAuthenticationProcessingFilter 只是一个虚类,查看 Spring Security API 文档 可以看到 Spring Security 提供了几个实现类:
CasAuthenticationFilter
OAuth2LoginAuthenticationFilter
OpenIDAuthenticationFilter
UsernamePasswordAuthenticationFilter
最常使用的应该是 UsernamePasswordAuthenticationFilter,其它类都应用于特定的场景。
AbstractAuthenticationProcessingFilter 类将构造的 Authentication 请求对象呈现给 AuthenticationManagerAbstractAuthenticationProcessingFilter 类有以下方法设置和获取 AuthenticationManager

protected AuthenticationManager getAuthenticationManager()

public void setAuthenticationManager(AuthenticationManager authenticationManager)

AuthenticationManager 只是一个接口,Spring Security 提供了一个默认实现 ProviderManagerProviderManager 在接收到 AbstractAuthenticationProcessingFilter 传递过来的 Authentication 请求对象后并不会执行认证处理,它持有一个 AuthenticationProvider 的列表,ProviderManager 委托列表中的 AuthenticationProvider 处理认证请求;
AuthenticationProvider 也只是接口,Spring Security 提供了很多此接口的实现,如 DaoAuthenticationProviderLdapAuthenticationProviderJaasAuthenticationProvider 等,现在暂时不关心这些具体实现。列表中的 AuthenticationProvider 会依次对 Authentication 请求对象进行认证处理,如果认证通过则返回一个完全填充的 Authentication 对象(后面会解释什么是“完全填充”),如果认证不通过则抛出一个异常(注意对抛出的异常有类型要求)或直接返回 null。如果列表中的所有 AuthenticationProvider 都返回 null,则 ProviderManager 会抛出 ProviderNotFoundException 异常;
⑤ 认证通过后 AuthenticationProvider 返回完全填充的 Authentication 对象给 ProviderManagerProviderManager 继续向上返回给 AbstractAuthenticationProcessingFilterAbstractAuthenticationProcessingFilter 会继续返回。
⑥ Spring Security 的“authentication mechanism”在接收到一个完全填充的 Authentication 对象返回后会认定认证请求有效,并将此 Authentication 对象放入 SecurityContextHolder
SecurityContextHolder 是 Spring Security 最基础的对象,用于存储应用程序当前安全上下文的详细信息,这些信息后续会被用于授权。

核心组件

这一节主要介绍一些在Spring Security中常见且核心的Java类,它们之间的依赖,构建起了整个框架。想要理解整个架构,最起码得对这些类眼熟。

SecurityContextHolder

SecurityContextHolder 用于存储安全上下文(security context)的信息。当前操作的用户是谁,该用户是否已经被认证,他拥有哪些角色权限…这些都被保存在SecurityContextHolder中。SecurityContextHolder 默认使用 ThreadLocal 策略来存储认证信息。看到ThreadLocal 也就意味着,这是一种与线程绑定的策略。Spring Security在用户登录时自动绑定认证信息到当前线程,在用户退出时,自动清除当前线程的认证信息。但这一切的前提,是你在web场景下使用Spring Security,而如果是Swing界面,Spring也提供了支持,SecurityContextHolder的策略则需要被替换,鉴于我的初衷是基于web来介绍Spring Security,所以这里以及后续,非web的相关的内容都一笔带过。

获取当前用户的信息
因为身份信息是与线程绑定的,所以可以在程序的任何地方使用静态方法获取用户信息。一个典型的获取当前登录用户的姓名的例子如下所示

Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();

if (principal instanceof UserDetails) {
String username = ((UserDetails)principal).getUsername();
} else {
String username = principal.toString();
}

getAuthentication()返回了认证信息,再次getPrincipal()返回了身份信息,UserDetails便是Spring对身份信息封装的一个接口。Authentication和UserDetails的介绍在下面的小节具体讲解,本节重要的内容是介绍SecurityContextHolder这个容器。

Authentication

先看看这个接口的源码长什么样:

package org.springframework.security.core;// <1>

public interface Authentication extends Principal, Serializable { // <1>
    Collection<? extends GrantedAuthority> getAuthorities(); // <2>

    Object getCredentials();// <2>

    Object getDetails();// <2>

    Object getPrincipal();// <2>

    boolean isAuthenticated();// <2>

    void setAuthenticated(boolean var1) throws IllegalArgumentException;
}

Authentication是spring security包中的接口,直接继承自Principal类,而Principal是位于java.security包中的。可以见得,Authentication在spring security中是最高级别的身份/认证的抽象。

由这个顶级接口,我们可以得到用户拥有的权限信息列表,密码,用户细节信息,用户身份信息,认证信息。

上面有提到,authentication.getPrincipal()返回了一个Object,我们将Principal强转成了Spring Security中最常用的UserDetails,这在Spring Security中非常常见,接口返回Object,使用instanceof判断类型,强转成对应的具体实现类。接口详细解读如下:

  • getAuthorities(),权限信息列表,默认是GrantedAuthority接口的一些实现类,通常是代表权限信息的一系列字符串。
  • getCredentials(),密码信息,用户输入的密码字符串,在认证过后通常会被移除,用于保障安全。
  • getDetails(),细节信息,web应用中的实现接口通常为 WebAuthenticationDetails,它记录了访问者的ip地址和sessionId的值。
  • getPrincipal(),敲黑板!!!最重要的身份信息,大部分情况下返回的是UserDetails接口的实现类,也是框架中的常用接口之一。UserDetails接口将会在下面的小节重点介绍。

Spring Security是如何完成身份认证的?

1 用户名和密码被过滤器获取到,封装成Authentication,通常情况下是UsernamePasswordAuthenticationToken这个实现类。
2 AuthenticationManager 身份管理器负责验证这个Authentication
3 认证成功后,AuthenticationManager身份管理器返回一个被填充满了信息的(包括上面提到的权限信息,身份信息,细节信息,但密码通常会被移除)Authentication实例。
4 SecurityContextHolder安全上下文容器将第3步填充了信息的Authentication,通过SecurityContextHolder.getContext().setAuthentication(…)方法,设置到其中。

这是一个抽象的认证流程,而整个过程中,如果不纠结于细节,其实只剩下一个AuthenticationManager 是我们没有接触过的了,这个身份管理器我们在后面的小节介绍。将上述的流程转换成代码,便是如下的流程:

public class AuthenticationExample {
private static AuthenticationManager am = new SampleAuthenticationManager();

public static void main(String[] args) throws Exception {
    BufferedReader in = new BufferedReader(new InputStreamReader(System.in));

    while(true) {
    System.out.println("Please enter your username:");
    String name = in.readLine();
    System.out.println("Please enter your password:");
    String password = in.readLine();
    try {
        Authentication request = new UsernamePasswordAuthenticationToken(name, password);
        Authentication result = am.authenticate(request);
        SecurityContextHolder.getContext().setAuthentication(result);
        break;
    } catch(AuthenticationException e) {
        System.out.println("Authentication failed: " + e.getMessage());
    }
    }
    System.out.println("Successfully authenticated. Security context contains: " +
            SecurityContextHolder.getContext().getAuthentication());
}
}

class SampleAuthenticationManager implements AuthenticationManager {
static final List<GrantedAuthority> AUTHORITIES = new ArrayList<GrantedAuthority>();

static {
    AUTHORITIES.add(new SimpleGrantedAuthority("ROLE_USER"));
}

public Authentication authenticate(Authentication auth) throws AuthenticationException {
    if (auth.getName().equals(auth.getCredentials())) {
    return new UsernamePasswordAuthenticationToken(auth.getName(),
        auth.getCredentials(), AUTHORITIES);
    }
    throw new BadCredentialsException("Bad Credentials");
}
}

注意:上述这段代码只是为了让大家了解Spring Security的工作流程而写的,不是什么源码。在实际使用中,整个流程会变得更加的复杂,但是基本思想,和上述代码如出一辙。

AuthenticationManager

初次接触Spring Security的朋友相信会被AuthenticationManagerProviderManagerAuthenticationProvider …这么多相似的Spring认证类搞得晕头转向,但只要稍微梳理一下就可以理解清楚它们的联系和设计者的用意。AuthenticationManager(接口)是认证相关的核心接口,也是发起认证的出发点,因为在实际需求中,我们可能会允许用户使用用户名+密码登录,同时允许用户使用邮箱+密码,手机号码+密码登录,甚至,可能允许用户使用指纹登录(还有这样的操作?没想到吧),所以说AuthenticationManager一般不直接认证,AuthenticationManager接口的常用实现类ProviderManager 内部会维护一个List<AuthenticationProvider>列表,存放多种认证方式,实际上这是委托者模式的应用(Delegate)。也就是说,核心的认证入口始终只有一个:AuthenticationManager,不同的认证方式:用户名+密码(UsernamePasswordAuthenticationToken),邮箱+密码,手机号码+密码登录则对应了三个AuthenticationProvider。这样一来四不四就好理解多了?熟悉shiro的朋友可以把AuthenticationProvider理解成Realm。在默认策略下,只需要通过一个AuthenticationProvider的认证,即可被认为是登录成功。

只保留了关键认证部分的ProviderManager源码:

public class ProviderManager implements AuthenticationManager, MessageSourceAware,
        InitializingBean {

    // 维护一个AuthenticationProvider列表
    private List<AuthenticationProvider> providers = Collections.emptyList();
          
    public Authentication authenticate(Authentication authentication)
          throws AuthenticationException {
       Class<? extends Authentication> toTest = authentication.getClass();
       AuthenticationException lastException = null;
       Authentication result = null;

       // 依次认证
       for (AuthenticationProvider provider : getProviders()) {
          if (!provider.supports(toTest)) {
             continue;
          }
          try {
             result = provider.authenticate(authentication);

             if (result != null) {
                copyDetails(authentication, result);
                break;
             }
          }
          ...
          catch (AuthenticationException e) {
             lastException = e;
          }
       }
       // 如果有Authentication信息,则直接返回
       if (result != null) {
            if (eraseCredentialsAfterAuthentication
                    && (result instanceof CredentialsContainer)) {
                 //移除密码
                ((CredentialsContainer) result).eraseCredentials();
            }
             //发布登录成功事件
            eventPublisher.publishAuthenticationSuccess(result);
            return result;
       }
       ...
       //执行到此,说明没有认证成功,包装异常信息
       if (lastException == null) {
          lastException = new ProviderNotFoundException(messages.getMessage(
                "ProviderManager.providerNotFound",
                new Object[] { toTest.getName() },
                "No AuthenticationProvider found for {0}"));
       }
       prepareException(lastException, authentication);
       throw lastException;
    }
}

ProviderManager 中的List<authenticationprovider>,会依照次序去认证,认证成功则立即返回,若认证失败则返回null,下一个AuthenticationProvider会继续尝试认证,如果所有认证器都无法认证成功,则ProviderManager 会抛出一个ProviderNotFoundException异常。

到这里,如果不纠结于AuthenticationProvider的实现细节以及安全相关的过滤器,认证相关的核心类其实都已经介绍完毕了:身份信息的存放容器SecurityContextHolder,身份信息的抽象Authentication,身份认证器AuthenticationManager及其认证流程。姑且在这里做一个分隔线。下面来介绍下AuthenticationProvider接口的具体实现。

DaoAuthenticationProvider

AuthenticationProvider最最最常用的一个实现便是DaoAuthenticationProvider。顾名思义,Dao正是数据访问层的缩写,也暗示了这个身份认证器的实现思路。由于本文是一个Overview,姑且只给出其UML类图:

image.png

按照我们最直观的思路,怎么去认证一个用户呢?用户前台提交了用户名和密码,而数据库中保存了用户名和密码,认证便是负责比对同一个用户名,提交的密码和保存的密码是否相同便是了。在Spring Security中。提交的用户名和密码,被封装成了UsernamePasswordAuthenticationToken,而根据用户名加载用户的任务则是交给了UserDetailsService,在DaoAuthenticationProvider中,对应的方法便是retrieveUser,虽然有两个参数,但是retrieveUser只有第一个参数起主要作用,返回一个UserDetails。还需要完成UsernamePasswordAuthenticationToken和UserDetails密码的比对,这便是交给additionalAuthenticationChecks方法完成的,如果这个void方法没有抛异常,则认为比对成功。比对密码的过程,用到了PasswordEncoder和SaltSource,密码加密和盐的概念相信不用我赘述了,它们为保障安全而设计,都是比较基础的概念。

如果你已经被这些概念搞得晕头转向了,不妨这么理解DaoAuthenticationProvider:它获取用户提交的用户名和密码,比对其正确性,如果正确,返回一个数据库中的用户信息(假设用户信息被保存在数据库中)。

UserDetails与UserDetailsService

上面不断提到了UserDetails这个接口,它代表了最详细的用户信息,这个接口涵盖了一些必要的用户信息字段,具体的实现类对它进行了扩展。

public interface UserDetails extends Serializable {

   Collection<? extends GrantedAuthority> getAuthorities();

   String getPassword();

   String getUsername();

   boolean isAccountNonExpired();

   boolean isAccountNonLocked();

   boolean isCredentialsNonExpired();

   boolean isEnabled();
}

它和Authentication接口很类似,比如它们都拥有username,authorities,区分他们也是本文的重点内容之一。Authentication的getCredentials()与UserDetails中的getPassword()需要被区分对待,前者是用户提交的密码凭证,后者是用户正确的密码,认证器其实就是对这两者的比对。Authentication中的getAuthorities()实际是由UserDetails的getAuthorities()传递而形成的。还记得Authentication接口中的getUserDetails()方法吗?其中的UserDetails用户详细信息便是经过了AuthenticationProvider之后被填充的。

public interface UserDetailsService {
   UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

UserDetailsService和AuthenticationProvider两者的职责常常被人们搞混,关于他们的问题在文档的FAQ和issues中屡见不鲜。记住一点即可,敲黑板!!!UserDetailsService只负责从特定的地方(通常是数据库)加载用户信息,仅此而已,记住这一点,可以避免走很多弯路。UserDetailsService常见的实现类有JdbcDaoImpl,InMemoryUserDetailsManager,前者从数据库加载用户,后者从内存中加载用户,也可以自己实现UserDetailsService,通常这更加灵活。

架构概览图

为了更加形象的理解上述我介绍的这些核心类,网上找到的一张图


image.png

一些Spring Security的过滤器还未囊括在架构概览中,如将表单信息包装成UsernamePasswordAuthenticationToken的过滤器,考虑到这些虽然也是架构的一部分,但是真正重写他们的可能性较小,所以打算放到后面的章节讲解。

案例

用户登陆时会被AuthenticationProcessingFilter拦截,调用AuthenticationManager的实现,而且AuthenticationManager会调用ProviderManager来获取用户验证信息(不同的Provider调用的服务不同,因为这些信息可以是在数据库上,可以是在LDAP服务器上,可以是xml配置文件上等),如果验证通过后会将用户的权限信息封装一个UserDetails放到spring的全局缓存SecurityContextHolder中,以备后面访问资源时使用。
访问资源(即授权管理)访问url时,会通过AbstractSecurityInterceptor拦截器拦截,其中会调用FilterInvocationSecurityMetadataSource的方法来获取被拦截url所需的全部权限,在调用授权管理器AccessDecisionManager,这个授权管理器会通过spring的全局缓存SecurityContextHolder获取用户的权限信息,还会获取被拦截的url和被拦截url所需的全部权限,然后根据所配的策略(有:一票决定,一票否定,少数服从多数等),如果权限足够,则返回,权限不够则报错并调用权限不足页面。

配置

有关于配置文件,在文章的开头已经给出,这里就不过多介绍了,下面对一些重点内容介绍一下

// 表示/resources/**、/lib/**、/timeout、/verifiCode 不需要过滤
<http security="none" pattern="/resources/**"/>
<http security="none" pattern="/lib/**"/>
<http security="none" pattern="/timeout"/>
<http security="none" pattern="/verifiCode"/>

form-login 表示表单认证方式,其实spring security默认采用的时 http-basic 认证方式(弹窗),当我们同时定义了 http-basic 和 form-login 元素时,form-login 将具有更高的优先级。即在需要认证的时候 Spring Security 将引导我们到登录页面,而不是弹出一个窗口。使用form-login认证时,当我们什么属性都不指定的时候 Spring Security 会为我们生成一个默认的登录页面。如果不想使用默认的登录页面,我们可以指定自己的登录页面。

authentication-success-handler-ref="successHandler" 表示验证成功时会调用 CustomAuthenticationSuccessHandler的onAuthenticationSuccess方法,authentication-failure-handler-ref="loginFailureHandler" 表示验证失败时会调用LoginFailureHandler的onAuthenticationFailure方法。

<form-login login-page='/login' authentication-success-handler-ref="successHandler" authentication-failure-handler-ref="loginFailureHandler"/>

流程

在界面点击登录的时候,被AuthenticationProcessingFilter拦截,依次调用AuthenticationManager中的ProviderManager列表进行验证,ProviderManager的authenticate方法内会调用UserDetailsService的loadUserByUsername方法从数据库或其它地方获取用户信息,customUserDetailsService 即是一个UserDetailsService接口的一个实现,可以看看它的loadUserByUsername方法

<authentication-provider user-service-ref="customUserDetailsService">
   <password-encoder ref="passwordManager"/>
</authentication-provider>
@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private IUserService userService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userService.selectByUserName(username);
        if (user == null) {
            throw new UsernameNotFoundException("User not found:" + username);
        }

        checkUserException(user);

        Collection<SimpleGrantedAuthority> authorities = new ArrayList<SimpleGrantedAuthority>();
        authorities.add(new SimpleGrantedAuthority("ROLE_USER"));

        for(String role:user.getRoleCode()){
            authorities.add(new SimpleGrantedAuthority(role));
        }

        UserDetails userDetails = new CustomUserDetails(user.getUserId(), user.getUserName(),
                user.getPasswordEncrypted(), true, true, true, true, authorities,user.getEmployeeId(),user.getEmployeeCode());
        return userDetails;
    }


    private void checkUserException(User user) {
        UserException ue = null;
        if (User.STATUS_LOCK.equalsIgnoreCase(user.getStatus())) {
            ue = new UserException(UserException.ERROR_USER_LOCKED, null);
        } else if (User.STATUS_EXPR.equalsIgnoreCase(user.getStatus())) {
            ue = new UserException(UserException.ERROR_USER_EXPIRED, null);
        } else if (user.getStartActiveDate() != null
                && user.getStartActiveDate().getTime() > System.currentTimeMillis()) {
            ue = new UserException(UserException.ERROR_USER_NOT_ACTIVE, null);
        } else if (user.getEndActiveDate() != null && user.getEndActiveDate().getTime() < System.currentTimeMillis()) {
            ue = new UserException(UserException.ERROR_USER_EXPIRED, null);
        }
        if (ue != null) {
            throw new RuntimeException(ue);
        }
    }


}
<password-encoder ref="passwordManager"/>

表示用户密码的加密方式,这里使用的是一个自定义Been,其源码如下:

public class PasswordManager implements PasswordEncoder, InitializingBean, SystemConfigListener {

    public static final String PASSWORD_COMPLEXITY_NO_LIMIT = "NO_LIMIT";
    public static final String PASSWORD_COMPLEXITY_DIGITS_AND_LETTERS = "DIGITS_AND_LETTERS";
    public static final String PASSWORD_COMPLEXITY_DIGITS_AND_CASE_LETTERS = "DIGITS_AND_CASE_LETTERS";

    private PasswordEncoder delegate;

    private String siteWideSecret = "my-secret-key";

    private String defaultPassword = "123456";

    /**
     * 密码失效时间 默认0 不失效
     */
    private Integer passwordInvalidTime = 0;

    /**
     * 密码长度
     */
    private Integer passwordMinLength = 8;

    /**
     * 密码复杂度
     */
    private String passwordComplexity = "no_limit";

    @Override
    public void afterPropertiesSet() throws Exception {
        delegate = new StandardPasswordEncoder(siteWideSecret);
    }

    @Override
    public String encode(CharSequence rawPassword) {
        return delegate.encode(rawPassword);
    }

    @Override
    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        if (StringUtil.isEmpty(encodedPassword)) {
            return false;
        }
        return delegate.matches(rawPassword, encodedPassword);
    }

    @Override
    public List<String> getAcceptedProfiles() {
        return Arrays.asList("DEFAULT_PASSWORD", "PASSWORD_INVALID_TIME", "PASSWORD_MIN_LENGTH", "PASSWORD_COMPLEXITY");
    }

    @Override
    public void updateProfile(String profileName, String profileValue) {
        if ("PASSWORD_INVALID_TIME".equalsIgnoreCase(profileName)) {
            this.passwordInvalidTime = Integer.parseInt(profileValue);
        } else if ("PASSWORD_MIN_LENGTH".equalsIgnoreCase(profileName)) {
            this.passwordMinLength = Integer.parseInt(profileValue);
        } else if ("PASSWORD_COMPLEXITY".equalsIgnoreCase(profileName)) {
            this.passwordComplexity = profileValue;
        } else if ("DEFAULT_PASSWORD".equalsIgnoreCase(profileName)) {
            this.defaultPassword = profileValue;
        }
    }
}

有关于这一块的内容,我有一篇专门的文章来介绍 密码安全

还需要注意的一点,这里有个登录前置拦截器:

<!-- 验证码拦截器 -->
<custom-filter ref="captchaVerifierFilter" before="FORM_LOGIN_FILTER"/>

spring security中已经默认有一套拦截器链,但有时并不能完全满足项目上的需求。默认的拦截器如下:

定义 custom-filter 时需要我们通过 ref 属性指定其对应关联的是哪个 Filter,此外还需要通过 position、before 或者 after 指定该 Filter 放置的位置。从上图中可以知道,FORM_LOGIN_FILTER 对应的就是 UsernamePasswordAuthenticationFilter,before="FORM_LOGIN_FILTER" 就表示将定义的 Filter 放在 FORM_LOGIN_FILTER 之前,也就是将captchaVerifierFilter(验证码拦截器)放在UsernamePasswordAuthenticationFilter之前。有关于 CaptchaVerifierFilter 的源码,也可以简单看看,其实就是判断验证码是否正确,不正确就带上错误信息跳转到登录界面,这里不再深入。

public class CaptchaVerifierFilter extends OncePerRequestFilter {
    @Autowired
    private ICaptchaManager captchaManager;

    @Autowired
    private CaptchaConfig captchaConfig;

    private RequestMatcher loginRequestMatcher;

    private String captchaField = "captcha";

    private String loginUrl = "/login";

    public CaptchaVerifierFilter() {
        setFilterProcessesUrl(this.loginUrl);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
            FilterChain filterChain) throws ServletException, IOException {
        if (captchaConfig.isEnableCaptcha(WebUtils.getCookie(httpServletRequest, CaptchaConfig.LOGIN_KEY))
                && requiresValidateCaptcha(httpServletRequest, httpServletResponse)) {
            Cookie cookie = WebUtils.getCookie(httpServletRequest, captchaManager.getCaptchaKeyName());
            String captchaCode = httpServletRequest.getParameter(getCaptchaField());
            if (cookie == null || StringUtils.isEmpty(captchaCode)
                    || !captchaManager.checkCaptcha(cookie.getValue(), captchaCode)) {
                httpServletRequest.setAttribute("error", true);
                httpServletRequest.setAttribute("code", "CAPTCHA_INVALID");
                httpServletRequest.setAttribute("exception",
                        new UserException(UserException.LOGIN_VERIFICATION_CODE_ERROR, null));
                httpServletRequest.getRequestDispatcher(loginUrl).forward(httpServletRequest, httpServletResponse);
                return;
            }
        }
        filterChain.doFilter(httpServletRequest, httpServletResponse);

    }

    public String getCaptchaField() {
        return captchaField;
    }

    public void setCaptchaField(String captchaField) {
        this.captchaField = captchaField;
    }

    public void setFilterProcessesUrl(String filterProcessesUrl) {
        this.loginRequestMatcher = new AntPathRequestMatcher(filterProcessesUrl);
    }

    protected boolean requiresValidateCaptcha(HttpServletRequest request, HttpServletResponse response) {
        return loginRequestMatcher.matches(request) && "POST".equalsIgnoreCase(request.getMethod());
    }
}

下面梳理一下流程:用户点击登录按钮-->xxx拦截-->校验验证码-->xxx处理-->调用customUserDetailsService的loadUserByUsername方法获取UserDetails并存起来-->身份认证-->根据认证结果执行不同的程序(认证成功对应authentication-success-handler-ref,认证失败对应authentication-failure-handler-ref)。

onAuthenticationSuccess

源码如下

public class CustomAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler implements SystemConfigListener{

    @Autowired
    private ApplicationContext applicationContext;

    private RequestCache requestCache = new HttpSessionRequestCache();

    private Logger logger = LoggerFactory.getLogger(getClass());

    private Map<String, IAuthenticationSuccessListener> listeners;

    public static final String DEFAULT_TARGET_URL = "DEFAULT_TARGET_URL";

    private final String loginOauthUrl = "/login?oauth";
    private final String loginUrl = "/login";
    private final String indexUrl = "/index";
    private final String refererStr = "Referer";
    private final String loginCasUrl = "/login/cas";
    private final String functionCodeStr = "functionCode";

    {
        setDefaultTargetUrl("/");
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
            Authentication authentication) throws IOException, ServletException {
        if(listeners == null) {
            listeners = applicationContext
                    .getBeansOfType(IAuthenticationSuccessListener.class);
        }

        String referer = request.getHeader(refererStr);
        if(referer != null && referer.endsWith(loginOauthUrl)){
            super.onAuthenticationSuccess(request, response, authentication);
            return;
        }

        clearAuthenticationAttributes(request);
        List<IAuthenticationSuccessListener> list = new ArrayList<>();
        list.addAll(listeners.values());
        Collections.sort(list);
        IAuthenticationSuccessListener successListener = null;
        try {
            for (IAuthenticationSuccessListener listener : list) {
                successListener = listener;
                successListener.onAuthenticationSuccess(request, response, authentication);
            }
            HttpSession session = request.getSession(false);
            session.setAttribute(User.LOGIN_CHANGE_INDEX,"CHANGE");
        } catch (Exception e) {
            logger.error("authentication success, but error occurred in " + successListener, e);
            HttpSession session = request.getSession(false);
            if (session != null) {
                session.invalidate();
            }
            request.setAttribute("error", true);
            request.setAttribute("exception", e);

            request.getRequestDispatcher("/login").forward(request, response);
            return;
        }

        String requestURI = request.getRequestURI();
        boolean isCas = requestURI.endsWith(loginCasUrl);
        if(isCas) {
            //拿到登录以前的url
            SavedRequest savedRequest = this.requestCache.getRequest(request, response);
            if (savedRequest != null) {
                String targetUrl = savedRequest.getRedirectUrl();
               /* String defaultTarget = getDefaultTargetUrl();
                if (!targetUrl.contains(functionCodeStr) && ! indexUrl.equalsIgnoreCase(defaultTarget)) {
                   targetUrl = getDefaultTargetUrl()+"?targetUrl="+targetUrl;
                }*/
                this.getRedirectStrategy().sendRedirect(request, response, targetUrl);
                return;
            }
        }

            handle(request, response, authentication);
    }

    @Override
    public List<String> getAcceptedProfiles() {
         return Arrays.asList(DEFAULT_TARGET_URL);
    }

    @Override
    public void updateProfile(String profileName, String profileValue) {
        if(StringUtil.isNotEmpty(profileValue)) {
            setDefaultTargetUrl(profileValue);
        }
    }
}

注意这个 private Map<String, IAuthenticationSuccessListener> listeners


得到的就是的IAuthenticationSuccessListener接口的实现类


主要有:GenerateTokenAuthenticationSuccessListener、DefaultAuthenticationSuccessListener、AuthenticationSuccessActivityListener、UserLoginInfoCollection。然后遍历listeners,调用各个listener的onAuthenticationSuccess方法。

GenerateTokenAuthenticationSuccessListener:保存 token 授权信息到redis中
DefaultAuthenticationSuccessListener:设置系统首页的一些配置
AuthenticationSuccessActivityListener:activity代办事项
UserLoginInfoCollection:用户登录记录

LoginFailureHandler

验证失败时的处理比较简单,主要就是返回错误码,然后跳转到登录界面。

public class LoginFailureHandler implements AuthenticationFailureHandler {
    private static final Logger log = LoggerFactory.getLogger(LoginFailureHandler.class);

    @Autowired
    private CaptchaConfig captchaConfig;


    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                                        AuthenticationException exception) throws IOException, ServletException {
        if (log.isDebugEnabled()) {
            log.debug("login failed");
        }
        if (captchaConfig.getWrongTimes() > 0) {
            captchaConfig.updateLoginFailureInfo(WebUtils.getCookie(request, CaptchaConfig.LOGIN_KEY));
        }
        request.setAttribute("error", true);
        request.setAttribute("code", "LOGIN_NOT_MATCH");
        request.setAttribute("exception", exception);
        request.getRequestDispatcher("/login").forward(request, response);
    }

}

大体的逻辑就是这样,但是中间还有太多太多细节,想要完全理解清除需要很大的精力,其实如果知道这个流程,就基本上可以根据项目上的需求去修改代码了。

基于投票的AccessDecisionManager实现

AccessDecisionManager

  • 提供了一个基于AccessDecisionVoter 接口和投票集合的授权机制。
  • supports:这个逻辑操作实际上包含两个方法,它们允许AccessDecisionManager 的实现
    类判断是否支持当前的请求。
  • decide:基于请求的上下文和安全配置,允许AccessDecisionManager 去核实访问是否被
    允许以及请求是否能够被接受。decide 方法实际上没有返回值,通过抛出异常来表明对
    请求访问的拒绝。

AbstractAccessDecisionManager implements AccessDecisionManager。AbstractAccessDecisionManager 实现类如下:


spring security文档
以下截图是文档的一部分

There are three concrete AccessDecisionManager s provided with Spring Security that tally the votes. The ConsensusBased implementation will grant or deny access based on the consensus of non-abstain votes. Properties are provided to control behavior in the event of an equality of votes or if all votes are abstain. The AffirmativeBased implementation will grant access if one or more ACCESS_GRANTED votes were received (i.e. a deny vote will be ignored, provided there was at least one grant vote). Like the ConsensusBased implementation, there is a parameter that controls the behavior if all voters abstain. The UnanimousBased provider expects unanimous ACCESS_GRANTED votes in order to grant access, ignoring abstains. It will deny access if there is any ACCESS_DENIED vote. Like the other implementations, there is a parameter that controls the behaviour if all voters abstain.
It is possible to implement a custom AccessDecisionManager that tallies votes differently. For example, votes from a particular AccessDecisionVoter might receive additional weighting, whilst a deny vote from a particular voter may have a veto effect.

在Hap的标准登录中,使用的是UnanimousBased投票机制,意识就是说需要所有的投票器都认证通过才算验证通过。

<!-- 认证管理器,确定用户,角色及相应的权限 -->
<beans:bean id="accessDecisionManager" class="org.springframework.security.access.vote.UnanimousBased">
    <!-- 投票器 -->
    <beans:constructor-arg>
        <beans:list>
            <beans:bean class="com.hand.hap.security.CustomWebExpressionVoter"/>
            <beans:bean class="org.springframework.security.access.vote.RoleVoter"/>
            <beans:bean class="org.springframework.security.access.vote.AuthenticatedVoter"/>
            <beans:bean class="com.hand.hap.security.PermissionVoter"/>
        </beans:list>
    </beans:constructor-arg>
</beans:bean>

追溯到UnanimousBased源码,查看对应的UML类图:

UnanimousBased类中只有一个方法,其实现如下

public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> attributes) throws AccessDeniedException {

    int grant = 0;
    int abstain = 0;

    List<ConfigAttribute> singleAttributeList = new ArrayList<ConfigAttribute>(1);
    singleAttributeList.add(null);

    for (ConfigAttribute attribute : attributes) {
        singleAttributeList.set(0, attribute);

        for (AccessDecisionVoter voter : getDecisionVoters()) {
            int result = voter.vote(authentication, object, singleAttributeList);

            if (logger.isDebugEnabled()) {
                logger.debug("Voter: " + voter + ", returned: " + result);
            }

            switch (result) {
            case AccessDecisionVoter.ACCESS_GRANTED:
                grant++;

                break;

            case AccessDecisionVoter.ACCESS_DENIED:
                throw new AccessDeniedException(messages.getMessage(
                        "AbstractAccessDecisionManager.accessDenied",
                        "Access is denied"));

            default:
                abstain++;

                break;
            }
        }
    }

    // To get this far, there were no deny votes
    if (grant > 0) {
        return;
    }

    // To get this far, every AccessDecisionVoter abstained
    checkAllowIfAllAbstainDecisions();
}

其实就是在循环遍历配置文件中的那几个 实现类的 vote 方法。

再通过IDEA中的DEBUG功能,发现是在 org.springframework.security.access.intercept.AbstractSecurityInterceptor 的 beforeInvocation 方法中调用了 UnanimousBased 的 decide 方法;

org.springframework.security.web.access.intercept.FilterSecurityInterceptor 中调用了beforeInvocation方法

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 151,511评论 1 330
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 64,495评论 1 273
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 101,595评论 0 225
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 42,558评论 0 190
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 50,715评论 3 270
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 39,672评论 1 192
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,112评论 2 291
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 29,837评论 0 181
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,417评论 0 228
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 29,928评论 2 232
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,316评论 1 242
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 27,773评论 2 234
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,253评论 3 220
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,827评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,440评论 0 180
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 34,523评论 2 249
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 34,583评论 2 249

推荐阅读更多精彩内容