web

Spring实战(九)-保护Web应用

2018-07-18  本文已影响135人  阳光的技术小栈

本文基于《Spring实战(第4版)》所写。

Spring Security简介

Spring Security是为基于Spring的应用程序提供声明式安全保护的安全性框架。Spring Security提供了完整的安全性解决方案,它能够在Web请求级别和方法调用级别处理身份认证和授权。因为基于Spring框架,所以Spring Security充分利用了依赖注入和面向切面的技术。

Spring Security从两个角度来解决安全性问题。它使用Servlet规范中的Filter保护Web请求并限制URL级别的访问。Spring Security还能够使用Spring AOP保护方法调用—借助于对象代理和使用通知,能够确保只有具备适当权限的用户才能访问安全保护的方法。

理解Spring Security的模块

第一件需要做的事就是将Spring Security模块添加到应用程序的类路径下。Spring Security 3.2分为11个模块,如下表

模块 描述
ACL 支持通过访问控制列表(access control list, ACL)为域对象提供安全性
切面(Aspects) 一个很小的模块,当使用Spring Security注解时,会使用基于AspectJ的切面,而不是使用标准的Spring AOP
CAS客户端(CAS Client) 提供与Jasig的中心认证服务(Central Authentication Service, CAS)进行集成的功能
配置(Configuration) 包含通过XML和Java配置Spring Security的功能支持
核心(Core) 提供Spring Security基本库
加密(Cryptography) 提供了加密和密码编码的功能
LDAP 支持基于LDAP进行认证
OpenID 支持使用OpenID进行集中式认证
Remoting 提供了对Spring Remoting的支持
标签库(Tag Library) Spring Security的JSP标签库
Web 提供了Spring Security基于Filter的Web安全性支持

应用程序的类路径下至少要包含Core和Configuration这两个模块。Spring Security经常被用于保护Web应用,这显然也是Spittr应用的场景,所以我们还需要添加Web模块。同时我们还会用到Spring Security的JSP标签库,所以我们需要将这个模块也添加进来。

过滤Web请求

Spring Security借助一系列Servlet Filter来提供各种安全性功能。借助与Spring的小技巧,我们只需配置一个Filter就可以了。

DelegatingFilterProxy是一个特殊的Servlet Filter,它本身所做的工作并不多。只是将工作委托给一个javax.servlet.Filter实现类,这个实现类作为一个<bean>注册在Spring应用的上下文中,如下图:

DelegatingFilterProxy把Filter的处理逻辑委托给Spring应用上下文中所定义的一个代理Filter bean

在传统的web.xml中配置Servlet和Filter和话,可以使用<filter>元素,如下所示:

<filter>
     <filter-name>springSecurityFilterChain</filter-name>
     <filter-class>
          org.springframework.web.filter.DelegatingFilterProxy
     </filter-class>
</filter>

在这里,最重要的是<filter-name>设置成了springSecurityFilterChain。这是因为我们马上就会将Spring Security配置在Web安全性之中,这里会有一个名为springSecurityFilterChain的Filter bean,DelegatingFilterProxy会将过滤逻辑委托给它。

如果你希望借助WebApplicationInitializer以Java的方式来配置DelegatingFilterProxy的话,那么我们所需要做的就是创建一个扩展的新类:

package spittr.config;

import org.springframework.security.web.context.AbstractSecurityWebApplicationInitializer;

public class SecurityWebInitializer extends AbstractSecurityWebApplicationInitializer {
}

AbstractSecurityWebApplicationInitializer实现了WebApplicationInitializer,因此Spring会发现它,并用它在Web容器中注册DelegatingFilterProxy。尽管我们可以重载它的appendFilters()或insertFilters()方法来注册自己选择的Filter,但是要注册DelegatingFilterProxy的话,我们并不需要重载任何方法。

不管我们通过web.xml还是通过AbstractSecurityWebApplicationInitializer的子类来配置DelegatingFilterProxy,它都会拦截发往应用中的请求,并将请求委托给ID为springSecurityFilterChain bean。

springSecurityFilterChain本身是另一个特殊的Filter,它也被称为FilterChainProxy。它可以链接任意一个或多个其他的Filter。Spring Security依赖一系列Servlet Filter来提供不同的安全特性。但是,你几乎不需要知道这些细节,因为你不需要显式声明springSecurityFilterChain以及它所链接在一起的其他Filter。当我们启动Web安全性的时候,会自动创建这些Filter。

编写简单的安全性配置

Spring 3.2后,如下的程序清单展现了Spring Security最简单的Java配置。

package spittr.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@EnableWebSecurity   // 启动Web安全性
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}

顾名思义,@EnableWebSecurity注解将会启用Web安全功能。但它本身并没有什么用处,Spring Security必须配置在一个实现了WebSecurityConfigurer的bean中,或者(简单起见)扩展WebSecurityConfigurerAdapter。在Spring应用上下文中,任何实现了WebSecurityConfigurer的bean都可以用来配置Spring Security,但是最为简单的方式还是像上面程序清单那样扩展WebSecurityConfigurerAdapter类。

@EnableWebSecurity可以启用任意Web应用的安全性功能,看起来似乎并没有做太多的事情,但安全配置类会给应用产生很大的影响,将应用严格锁定,导致没有人能够进入该系统了!

尽管不是严格要求的,但我们可能希望指定Web安全的细节,这要通过重载WebSecurityConfigurerAdapter中的一个或多个方法来实现。我们可以通过重载WebSecurityConfigurerAdapter的三个configure()方法来配置Web安全性,这个过程中会使用传递进来的参数设置行为。下表描述了这三个方法。

方法 描述
configure(WebSecurity) 通过重载,配置Spring Security的Filter链
configure(HttpSecurity) 通过重载,配置如何通过拦截器保护请求
configure(AuthenticationManagerBuilder) 通过重载,配置user-detail服务

我们之前的Java配置的Spring Security没有重写上述三个configure()方法中的任何一个,这就说明了为什么应用现在是被锁定的。尽管对于我们的需求来讲默认的Filter链是不错的,但是默认的configure(HttpSecurity)实际上等同于如下所示:

protected void configure(HttpSecurity http) throws Exception {
   http
      .authorizeRequests()
          .anyRequest().authenticated()
          .and()
      .formLogin().and()
      .httpBasic();
}

这个简单的默认配置指定了该如何保护HTTP请求,以及客户端认证用户的方案。通过调用authorizeRequests()和anyRequest().authenticated()就会要求所有进入应用的HTTP请求都要进行认证。它也配置Spring Security支持基于表单的登录以及HTTP Basic方式的认证。

同时,因为我们没有重载configure(AuthenticationManagerBuilder)方法,所以没有用户存储支撑认证过程。没有用户存储,实际上就等于没有用户。所以在这里所有的请求都需要认证,但是没有人能够登录成功。

为了让Spring Security满足我们应用的需要,还需要再添加一点配置。具体来讲,我们需要:

除了Spring Security的这些功能,我们可能还希望基于安全限制,有选择性在Web视图上显示特定的内容。

选择查询用户详细信息的服务

我们所需要的是用户存储,也就是用户名、密码以及其他信息存储的地方,在进行认证决策的时候会对其进行检索。

好消息是,Spring Security非常灵活,能够基于各种数据存储来认证用户。它内置了多种常见的用户存储场景,如内存、关系型数据库以及LDAP。但我们也可以编写并插入自定义的用户存储实现。

借助Spring Security的Java配置,我们能够很容易地配置一个或多个数据存储方案。

使用基于内存的用户存储

因为我们的安全配置类扩展了WebSecurityConfigurerAdapter,因此配置用户存储的最简单方式就是重载configure()方法,并以AuthenticationManagerBuilder作为传入参数。AuthenticationManagerBuilder有多个方法可以用来配置Spring Security对认证的支持。通过inMemoryAuthentication()方法,我们可以启动、配置并任意填充基于内存的用户存储。

例如,以下程序中,SecurityConfig重载了configure()方法,并使用两个用户来配置内存用户存储。

package spittr.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.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()  // 启动内存用户存储
                .withUser("user").password("password").roles("USER").and()
                .withUser("admin").password("password").roles("USER","ADMIN");
    }
}

我们可以看到,configure() 方法中的AuthenticationManagerBuilder使用构造者风格的接口来构建认证配置。通过简单地调用inMemoryAuthentication()就能启动内存用户存储。但是我们还需要有一些用户,否则的话,这和没有用户并没有什么区别。

因此,我们需要调用withUser()方法为内存用户存储添加新的用户,这个方法的参数是username。withUser()方法返回的是UserDetailsManagerConfigurer.UserDetailsBuilder,这个对象提供了多个进一步配置用户的方法,包括设置用户密码的password()方法以及为给定用户授予一个或多个角色权限的roles()方法。

在以上程序中,我们添加了两个用户,“user”和“admin”,密码均为“password”。“user”用户具有USER角色,而“admin”用户具有ADMIN和USER两个角色。我们可以看到,and()方法能够将多个用户的配置连接起来。

除了password()、roles()和and()方法以外,还有其他的几个方法可以用来配置内存用户存储中的用户信息。下表描述了UserDetailsManagerConfigurer.UserDetailsBuilder对象所有可用的方法。

需要注意的是,roles()方法是authorities()方法的简写形式。roles()方法所给定的值都会添加一个“ROLE ”前缀,并将其作为权限授予给用户。实际上,如下的用户配置与以上程序是等价的

auth
    .inMemoryAuthentication()
       .withUser("user").password("password").authorities("ROLE_USER")
       .and()
       .withUser("admin").password("password")
           .authorities("ROLE_USER", "ROLE_ADMIN");
方法 描述
accountExpired(boolean) 定义账号是否已经过期
accountLocked(boolean) 定义账号是否已经锁定
and() 用来连接配置
authorities(GrantedAuthority...) 授予某个用户一项或多项权限
authorities(List<? extends GrantedAuthority>) 授予某个用户一项或多项权限
authorities(String...) 授予某个用户一项或多项权限
credentialsExpired(boolean) 定义凭证是否已经过期
disabled(boolean) 定义账号是否已经被禁用
password(String) 定义用户的密码
roles(String...) 授予某个用户一项或多项角色

基于数据库表进行认证

用户数据通常会存储在关系型数据库中,并通过JDBC进行访问。为了配置Spring Security使用以JDBC为支撑的用户存储,我们可以使用jdbcAuthentication()方法,所需的最少配置如下所示:

    @Autowired
    private DataSource dataSource;

    /**
     * 配置Spring Security的Filter链
     * @param auth
     * @throws Exception
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.jdbcAuthentication()
                .dataSource(dataSource);
    }

我们必须要配置的只是一个DataSource,这样的话,就能访问关系型数据库了。在这里,DataSource是通过自动装配的技巧得到的。

重写默认的用户查询功能

尽管默认的最少配置能够让一切运转起来,但是它对我们的数据库模式有一些要求。它预期存在某些存储用户数据的表。更具体来说,下面的代码片段来源于Spring Security内部,这块代码展现了当查找用户信息时所执行的SQL查询语句:

public static final String DEF_AUTHORITIES_BY_USERNAME_QUERY = 
        "select username,password,enabled " +
        "from users " + 
        "where username = ?";
public static final String DEF_AUTHORITIES_BY_USERNAME_QUERY = 
         "select username,authority " + 
         "from authorities " + 
         "where username = ?";
public static final String DEF_GROUP_AUTHORITIES_BY_USERNAME_QUERY = 
         "select g.id, g.group_name, ga.authority " + 
         "from group g, group_members gm, ga.authorities ga " + 
         "where gm.username = ?"
         "and g.id = ga.group_id " + 
         "and g.id = gm.group_id";

第一个查询中,我们获取了用户的用户名、密码以及是否启用的信息,这些信息会用来进行用户认证。接下来的查询查找了用户所授予的权限,用来进行鉴权,最后一个查询中,查找了用户作为群组的成员所授予的权限。

如果你能够在数据库中定义和填充满足这些查询的表,那么基本上就不需要你再做什么额外的事情了。但是,也有可能你的数据库与默认所属并不一致,那么你就会希望在查询上有更多的控制权。如果是这样的话,我们可以按照如下的方式配置自己的查询:

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.jdbcAuthentication()
                .dataSource(dataSource)
                .usersByUsernameQuery(
                        "select username, password,  true " +
                        "from Spitter where username=?")
                .authoritiesByUsernameQuery(
                        "select username, 'ROLE_USER' from Spitter where username=?");
    }

在本例中,我们只重写了认证和基本权限的查询语句,但是通过调用groupAuthoritiesByUsername()方法,我们也能够将群组权限重写为自定义的查询语句。

将默认的SQL查询替换为自定义的设计时,很重要的一点就是要遵循查询的基本协议。所有查询都将用户名作为唯一的参数。认证查询会选取用户名、密码以及启用状态信息。权限查询会选取零行或多行包含该用户名以及其权限信息的数据。群组权限查询会选取零行或多行数据,每行数据中都会包含群组ID、群组名称以及权限。

使用转码后的密码

看一下上面的认账查询,它会预期用户密码存储在了数据库之中。这里唯一的问题在于如果密码明文存储的话,会很容易收到黑客的窃取。但是,如果数据库中的密码进行了转码的话,那么认证就会失败,因为它与用户提交的明文密码并不匹配。

为了解决这个问题,我们需要借助passwordEncoder() 方法指定一个密码转码器(encoder):

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.jdbcAuthentication()
                .dataSource(dataSource)
                .usersByUsernameQuery(
                        "select username, password,  true " +
                        "from Spitter where username=?")
                .authoritiesByUsernameQuery(
                        "select username, 'ROLE_USER' from Spitter where username=?")
                .passwordEncoder(new StandardPasswordEncoder("53cr3t"));
    }

passwordEncoder()方法可以接受Spring Security中PasswordEncoder接口的任意实现。Spring Security的加密模块包括了三个这样的实现:BCryptPasswordEncoder、NoOpPasswordEncoder和StandardPasswordEncoder.

上述的代码中使用了StandardPasswordEncoder,但是如果内置的实现无法满足需求时,你可以提供自定义的实现。PasswordEncoder接口非常简单:

public interface PasswordEncoder {
    String encode(CharSequence rawPassword);
    boolean matches(CharSequence rawPassword, String encodedPassword);
}

不管你使用哪一个密码转码器,都需要理解的一点是,数据库中的密码是永远不会解码的。所采取的策略与之相反,用户在登录时输入的密码会按照相同的算法进行转码,然后再在数据库中已经转码过的密码进行对比。这个对比是在PasswordEncoder的matches()方法中进行的。

配置自定义的用户服务

假设我们需要认证的用户存储在非关系型数据库中,如Mongo或Neo4j,在这种情况下,我们需要提供一个自定义的UserDetailsService接口实现。

UserDetailsService接口非常简单:

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

我们所需要做的就是实现loadUserByUsername() 方法,根据给定的用户名来查找用户。loadUserByUsername() 方法会返回代表给定用户的UserDetails对象。如下的程序清单展现了一个UserDetailsService的实现,它会从给定的SpitterRepository实现中查找用户。

package spittr.config;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import spittr.data.SpitterRepository;
import spittr.model.Spitter;

import java.util.ArrayList;
import java.util.List;

public class SpitterUserService implements UserDetailsService {

    private final SpitterRepository spitterRepository;

    //注入SpitterRepository
    public SpitterUserService(SpitterRepository spitterRepository){  
        this.spitterRepository = spitterRepository;
    }

    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 查找Spitter
        Spitter spitter = spitterRepository.findByUsername(username);
        
        if (spitter != null) {
            List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
            // 创建权限列表
            authorities.add(new SimpleGrantedAuthority("ROLE_SPITTER"));
            
            return new User(  // 返回User
                    spitter.getUsername(), 
                    spitter.getPassword(),
                    authorities);
        }
        throw new UsernameNotFoundException("User '" + username + "' not found.");
    }
}

SpitterUserService不知道也不会关心底层所使用的数据存储。它只是获得Spitter对象,并使用它来创建User对象。(User是UserDetails的具体实现。)

为了使用SpitterUserService来认证用户,我们可以通过userDetailsService()方法将其设置到安全配置中:

 @Autowired
 SpitterRepository spitterRepository;
    
 @Override
 protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(new SpitterUserService(spitterRepository));
 }

userDetailsService()方法(类似于jdbcAuthentication()、ladpAuthentication()以及inMemoryAuthentication())会配置一个用户存储。不过,这里所使用的不是Spring所提供的用户存储,而是使用
UserDetailsService的实现。

另一种值得考虑的方法就是修改Spitter,让其实现UserDetails。这样的话,loadUserByUsername() 就能直接返回Spitter对象了,而不必再将它的值复制到User对象中。

拦截请求

在任何应用中,并不是所有的请求都需要同等程度地保护。有些请求需要认证,而另一些可能并不需要。有些请求可能只有具备特定权限的用户才能访问,没有这些权限的用户会无法访问。

例如,考虑Spittr应用的请求。首页当然是公开的,不需要进行保护。类似地,因为所有的Spittle都是公开的,所以展现Spittle的页面不需要安全性。但是,创建Spittle的请求只有认证用户才能执行。同样,尽管用户基本信息页面是公开的,不需要认证,但是如果要处理“/spitters/me”请求,并展现当前用户的基本信息时,那么就需要进行认证,从而确定要展现谁的信息。

对每个请求进行细粒度安全性控制的关键在于重载configure(HttpSecurity)方法。如下的代码片段展现了重载的configure(HttpSecurity)方法,它为不同的URL路径有选择地应用安全性:

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
           .authorizeRequests()
                .antMatchers("/spitter/me").authenticated()
                .antMatchers(HttpMethod.POST, "/spittles").authenticated()
                .anyRequest().permitAll();
    }

configure()方法中得到的HttpSecurity对象可以在多个方法配置HTTP的安全性。在这里,我们首先调用authorizeRequests(),然后调用该方法所返回的对象的方法来配置请求级别的安全性细节。其中,第一次调用antMatchers()指定了对“/spitters/me”路径的请求需要进行认证。第二次调用antMatchers()更为具体,说明对“/spittles”路径的HTTP POST请求必须要经过认证。最后对anyRequests() 的调用中,说明其他所有的请求都是允许的,不需要认证和任何的权限。

antMatchers() 方法中设定的路径支持Ant风格的通配符。在这里我们并没有这样使用,但是也可以使用通配符来指定路径,如下所示:

.antMatchers("/spitters/**").authenticated();

我们也可以在一个对antMatchers()方法的调用中指定多个路径:

.antMatchers("/spitters/**", "spittles/mine").authenticated();

authenticated()方法所使用的路径可能会包括Ant风格的通配符,而regexMatchers()方法则能够接受正则表达式来定义请求路径。例如,如下代码片段所使用的正则表达式与“/spitters/**”(Ant风格)功能是相同的:

.regexMatchers("/spitters/.*").authenticated();

除了路径选择,我们还通过authenticated()和permitAll()来定义该如何保护路径。authenticated()要求在执行该请求时,必须已经登录了应用。如果用户没有认证的话,Spring Security的Filter将会捕获该请求,并将用户重定向到应用的登录页面。同时,permitAll()方法允许请求没有任何的安全限制。

除了authenticated()和permitAll()方法以外,还有其他的一些方法能够用来定义该如何保护请求。下表描述了所有可用的方案。

方法 能够做什么
access(String) 如果给定的SpEL表达式计算结果为true,就允许访问
anonymous() 允许匿名用户访问
authenticated() 允许认证过的用户访问
denyAll() 无条件拒绝所有访问
fullyAuthenticated() 如果用户是完整认证的话(不是通过Remember-me功能认证的),就允许访问
hasAnyAuthority(String...) 如果用户具备给定权限中的某一个的话,就允许访问
hasAnyRole(String...) 如果用户具备给定角色中的某一个的话,就允许访问
hasAuthority(String) 如果用户具备给定权限的话,就允许访问
hasIpAddress(String) 如果请求来自给定IP地址的话,就允许访问
hasRole(String) 如果用户具备给定角色的话,就允许访问
not() 对其他访问方法的结果求反
permitAll() 无条件允许访问
rememberMe() 如果用户是通过Remember-me功能认证的,就允许访问

通过上表的方法,我们所配置的安全性能够不仅仅限于认证用户。例如,我们可以修改之前的configure()方法,要求用户不仅需要认证,还要具备ROLE_SPITTER权限:

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
           .authorizeRequests()
                .antMatchers("/spitter/me").hasAuthority("ROLE_SPITTER")
                .antMatchers(HttpMethod.POST, "/spittles").hasAuthority("ROLE_SPITTER")
                .anyRequest().permitAll();
    }

作为替代方案,我们还可以使用hasRole()方法,它会自动使用“ROLE_”前缀:

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
           .authorizeRequests()
                .antMatchers("/spitter/me").hasRole("SPITTER")
                .antMatchers(HttpMethod.POST, "/spittles").hasRole("SPITTER")
                .anyRequest().permitAll();
    }

我们可以将任意数量的antMatchers()、regexMatchers()和anyRequest()连接起来,以满足Web应用安全规则的需要。但是,我们需要知道,这些规则会按照给定的顺序发挥作用。所以,很重要的一点就是将最为具体的请求路径放在前面,而最不具体的路径(如anyRequest())放在最后面。如果不这样做的话,那不具体的路径配置将会覆盖掉更为具体的路径配置。

使用Spring表达式进行安全保护

定义保护路径配置的方法表的大多数方法都是一维的,也就是说我们可以使用hasRole() 限制某个特定的角色,但是我们不能在相同的路径上同时通过hasIpAddress() 限制特定的IP地址。

借助access()方法,我们也可以将SpEL作为声明访问限制的一种方式。例如,如下就是使用SpEL表达式来声明具有“ROLE_SPITTER”角色才能访问“/spitter/me”URL:

.antMatchers("/spitter/me").access("hasRole('ROLE_SPITTER')")

这个对“/spitter/me”的安全限制与开始时的效果是等价的,只不过这里使用了SpEL来描述安全规则。如果当前用户被授予了给定角色的话,那hasRole()表达式的计算结果就为true。

让SpEL更强大的原因在于,hasRole() 仅是Spring支持的安全相关表达式中的一种,下表列出了Spring Security支持的所有SpEL表达式。

安全表达式 计算结果
authentication 用户的认证对象
denyAll 结果始终为false
hasAnyRole(list of roles) 如果用户被授予了列表中任意的指定角色,结果为true
hasRole(role) 如果用户被授予了指定的角色,结果为true
hasIpAddress(IPAddress) 如果请求来自指定的IP的话,结果为true
isAnonymous() 如果当前用户为匿名用户,结果为true
isAuthenticated() 如果当前用户进行了认证的话,结果为true
isFullAuthenticated() 如果当前用户进行了完整认证的话(不是用过Remember-me功能进行的认证),结果为true
isRememberMe() 如果当前用户是通过Remember-me自动认证的话,结果为true
permitAll 结果始终为true
principal 用户的principal对象

在掌握了Spring Security的SpEL表达式后,我们就能够不再局限于基于用户的权限进行访问限制了。例如,如果你想限制“/spitter/me”URL的访问,不仅需要ROLE_SPITTER,还需要来自指定的IP地址,那么我们可以按照如下的方式调用access()方法:

.antMatchers("/spitter/me")
    .access("hasRole('ROLE_SPITTER') and hasIpAddress('192.168.1.2')")

我们可以使用SpEL实现各种各样的安全性限制。

还有一种Spring Security拦截请求的方式:强制通道的安全性。

强制通道的安全性

使用HTTP提交数据是一件具有风险的事情。通过HTTP发送的数据没有经过加密,黑客就有机会拦截请求并且能够看到他们想看的数据。这就是为什么敏感信息要通过HTTPS来加密发送的原因。

传递到configure() 方法中的HttpSecurity对象,除了具有authorizeRequests() 方法以外,还有一个requiresChannel() 方法,借助这个方法能够为各种为各种URL模式声明所要求的通道。

作为示例,可以参考Spittr应用的注册表单。尽管Spittr应用不需要信用卡号等敏感的信息,但用户有可能仍然希望信息是私密的。为了保证注册表单的数据通过HTTPS传送,我们可以在配置中添加requiresChannel() 方法,如下所示:


    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/spitter/me").hasRole("SPITTER")
                .antMatchers(HttpMethod.POST, "/spittles").hasRole("SPITTER")
                .anyRequest().permitAll()
              .and() 
              .requiresChannel()
                .antMatchers("/spitter/form").requiresSecure();   // 需要 HTTPS
    }

不论何时,只要是对“/spitter/form”的请求,Spring Security都视为需要安全通道(通过调用requiresChannel()确定)并自动将请求重定向到HTTPS上。

与之相反,有些页面并不需要通过HTTPS传送。例如,首页不包含任何敏感信息,因此并不需要通过HTTPS传送。我们可以使用requiresInsecure()代替requiresSecure()方法,将首页声明为始终通过HTTP传送:

.antMatchers("/").requiresInsecure();

如果通过HTTPS发送了对“/”的请求,Spring Security将会把请求重定向到不安全的HTTP通道上。

在强制要求通道时,路径的选取方案与authorizeRequests()是相同的。程序中,除了可以使用antMatchers(),我们也可以使用regexMatchers()方法,通过正则表达式选取路径模式。

防止跨站请求伪造

当一个POST请求提交到“/spittles”上,SpittleController将会为用户创建一个新的Spittle对象。但是如果这个POST请求来源于其他站的话,这就是跨站请求伪造(cross-site request forgery,CSRF)的一个简单样例。简单来讲,如果一个站点欺骗用户提交请求到其他服务器的话,就会发生CSRF攻击,这可能会带来消极的后果,比如可能会对你的银行账号执行难以预期的操作。

从Spring Security 3.2开始,默认就会启用CSRF防护。实际上,除非你采取行为处理CSRF防护或者将这个功能禁用,否则的话,在应用中提交表单时,可能会遇到问题。

Spring Security通过一个同步token的方式来实现CSRF防护的功能。它将会拦截状态变化的请求(例如,非GET、HEAD、OPTIONS和TRACE的请求)并检查CSRF token。如果请求中不包含CSRF token的话,或者token不能与服务器端的token相匹配,请求将会失败,并抛出CsrfException异常。

这意味着在你的应用中,所有的表单必须在一个“_csrf ”域中提交token,而且这个token必须要与服务器端计算并存储的token一致,这样的话当表单提交的时候,才能进行匹配。

好消息是,Spring Security已经简化了将token放到请求的属性中这一任务。如果使用JSP作为页面模板的话,我们要做的事情:

<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />

更好的功能是,如果使用Spring的表单绑定标签的话,<sf:form>便签会自动为我们添加隐藏的CSRF token标签。

处理CSRF的另一种方式就是根本不去处理它。我们可以在配置中通过调用csrf().disable()禁用Spring Security的CSRF防护功能,如下所示:

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.
           ...
           .csrf()
              .disable();     //禁用CSRF防护功能 
    }

需要提醒的是,禁用CSRF防护功能通常来讲并不是一个好主意。如果这样做的话,那么应用就会面临CSRF攻击的风险。只要在深思熟虑之后,才能禁用CSRF防护功能。

认证用户

如果你使用程序中最简单的Spring Security配置的话,那么就能无偿地得到一个登录页。实际上,在重写configure(HttpSecurity)之前,我们都能使用一个简单却功能完备的登录页。但是,一旦重写了configure(HttpSecurity)方法,就失去了这个简单的登录页面。

不过,把这个功能找回来也很容易。我们所需要做的就是在configure(HttpSecurity)方法中,调用formLogin(),如下面的程序清单所示。

请注意,和前面一样,这里调用and()方法来将不同的配置指令连接在一起。

如果我们访问应用的“/login”链接或者导航到需要认证的页面,那么将会在浏览器中展现登录页面。如下图所示,在审美上它没有什么令人兴奋的,但是它却能实现所需的功能。

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
           .formLogin()   // 启用默认的登录页
              .and()
           .authorizeRequests()
                .antMatchers("/spitter/me").hasRole("SPITTER")
                .antMatchers(HttpMethod.POST, "/spittles").hasRole("SPITTER")
                .anyRequest().permitAll()
              .and() 
              .requiresChannel()
                .antMatchers("/spitter/form").requiresSecure();  
    }
默认的登录页在审美上过于简陋,但是功能完备

添加自定义的登录页

如下程序清单所展现的JSP模板提供了一个与Spittr应用风格一致的登录页。

<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<%@ page session="false" language="java" pageEncoding="UTF-8" contentType="text/html; charset=utf-8"%>
<%@ taglib prefix="sf" uri="http://www.springframework.org/tags/form" %>
<%@ page isELIgnored="false"%>
<!DOCTYPE HTML>
<html>
<head>
    <title>Spitter Login</title>
    <link rel="stylesheet" type="text/css"
          href="<c:url value="/resources/style.css" />" >
</head>
<body onload="document.f.username.focus();">
<h3>Login</h3>

<form name='f' action='/login'  method='POST'>
    <table>
        <tr><td>User:</td><td>
            <input type="text" name="username" /></td></tr>
        <tr><td>Password:</td>
            <td><input type="password" name="password" /></td></tr>
        <tr><td colspan='2'>
            <input name="submit" type="submit" value="Login"/></td></tr>
    </table>
    <input type="hidden" name="${_csrf.parameterName}"   value="${_csrf.token}" />
</form>

<%--以下是使用了Spring Form的标签--%>
<%--<sf:form name="f" method="POST"  action="/login">--%>
    <%--<sf:errors path="*" element="div" cssClass="errors"/>--%>
    <%--Username: <sf:input path="username"/><br/>--%>
    <%--Password: <sf:password path="password"/><br/>--%>
    <%--<input type="submit" value="Login" />--%>
<%--</sf:form>--%>
</body>
</html>

启用HTTP Basic认证

除了表单提交,我们还会经常看到Web应用的页面转化为RESTful API。

HTTP Basic认证(HTTP Basic Authentication)会直接通过 HTTP请求本身,对要访问应用程序的用户进行认证。你可能在以前见过HTTP Basic认证。当在Web浏览其中使用,它将想用户弹出一个简单的模态对话框。

本质上,这是一个HTTP 401响应,表明必须要在请求中包含一个用户名和密码。在REST客户端它使用的服务进行认证的场景中,这种方式比较适合。

如果要启用HTTP Basic认证的话,只需在configure()方法所传入的HttpSecurity对象上调用httpBasic()即可。另外,还可以通过调用realmName()方法指定域。如下是在Spring Security中启用HTTP Basic认证的典型配置:

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                .loginPage("/login")// 如果不设置,则默认为/login
                .and()
                .httpBasic()
                   .realmName("Spittr")
                .and()
        ...
    }

注意,和前面一样,在configure()方法中,通过调用and()方法来将不同的配置指令连接在一起。

在httpBasic()方法中,并没有太多的可配置项,甚至不需要什么额外配置。HTTP Basic认证要么开启要么关闭。

启用Remember-me功能

许多站点提供了Remember-me功能,你只要登录过一次,应用就会记住你,当再次回到应用的时候你就不需要登录了。

Spring Security使得为应用添加Remember-me功能变得非常容易。为了启用这项功能,只需在configure()方法所传入的HttpSecurity对象上调用rememberMe()即可。

  @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                .loginPage("/login")// 如果不设置,则默认为/login
              .and()
                .rememberMe()
                    .tokenValiditySeconds(2419200)
                    .key("spittrKey"); // cookie中的私钥
    }

在这里,我们通过一点特殊的配置就可以启用Remember-me功能。默认情况下,这个功能是通过在cookie中存储一个token完成的,这个token最多两周内有效。但是,在这里,我们指定这个token最多四周内有效(2,419,200秒)。

存储在cookie中的token包含用户名、密码、过期时间和一个私钥—在写入cookie前都进行了MD5哈希。默认情况下,私钥的名为SpringSecured,但在这里我们将其设置为spitterKey,使它专门用户Spittr应用。

如此简单。既然Remember-me功能已经启用,我们需要有一种方式来让用户表明他们希望应用程序能够记住他们。为了实现这一点,登录请求必须包含一个名为remember-me的参数。在登录表单中,增加一个简单复选框就可以完成这件事情:

<input id="remember_me" name="remember-me" type="checkbox"/>
<label for="remember_me" class="inline">Remember me</label>

退出

在应用中,与登录同等主要的功能就是退出。其实,按照我们的配置,退出功能已经启用了,不需要再做其他的配置了。我们需要的只是一个使用该功能的链接。

退出功能通过Servlet容器中Filter实现的(默认情况下),这个Filter会拦截对“/logout”的请求。因此,为应用添加退出功能只需要添加如下的链接即可:

 <a href="<c:url value="/logout" />">Logout</a>

当用户点击这个链接的时候,会发起对“/logout”的请求,这个请求会被Spring Security的LogoutFilter所处理。用户会退出应用,所有Remember-me token都会被清除掉。在退出完成后,用户浏览器将会重定向“/login?logout”,从而允许用户进行再次登录。

如果你希望用户被重定向到其他的页面,如应用的首页,那么可以在configure()中进行如下的配置:

  @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                .loginPage("/login")// 如果不设置,则默认为/login
                .and()
                  .logout()
                  .logoutSuccessUrl("/")
        ...
    }

在这里,和前面一样,通过andI()连接起了对logout()的调用。logout()提供了配置退出行为的方法。在本例中,调用logoutSuccessUrl()表明在退出成功之后,浏览器需要重定向到“/”。

除了logoutSuccessUrl()方法以外,你可能还希望重写默认的LogoutFilter拦截路径。我们可以通过调用logoutUrl()方法实现这一功能:

.logout()
   .logoutUrl("/signout")    // 自定义访问退出地址
   .logoutSuccessUrl("/");   // 自定义退出后的重定向地址

保护视图

为了在浏览器渲染HTML内容时,在视图中能够反映安全限制和相关的信息,我们可以通过Spring Security本身提供的JSP标签库或者Thymeleaf实现与Spring Security的集成。

使用Spring Security的JSP标签库

Spring Security的JSP标签库很小,只包含三个标签,如下表。

JSP标签 作用
<security:accesscontrollist> 如果用户通过访问控制列表授予了指定的权限,那么渲染该标签体中的内容
<security:authentication> 渲染当前用户认证对象的详细信息
<security:authorize> 如果用户被授予了特定的权限或者SpEL表达式的计算结果为true,那么渲染该标签体中的内容

为了使用JSP标签库,我们需要在对应的JSP中声明它:

<%@ taglib prefix="security" 
              uri="http://www.springframework.org/security/tags" %>

只要标签库在JSP文件中进行了声明,我们就可以使用它了。

访问认证信息的细节

借助Spring Security JSP标签库,所能做到的最简单的一件事情就是便利地访问用户的认证信息。例如,对于Web站点来讲,在页面顶部以用户名标示显示“欢迎”或“您好”信息是很常见的。这恰恰是<security:authentication>能为我们所做的事情。例如:

Hello <security:authentication property="principal.username" />!

其中,property用来标示用户认证对象的一个属性。可用的属性取决于用户认证的方式。但是,我们可以依赖几个通用的属性,在不同的认证方式下,他们都是可用的,如下表所示

认证属性 描述
authorities 一组用于表示用户所授予权限的GrantedAuthority对象
Credentials 用于核实用户的凭证(通常,这会是用户的密码)
details 认证的附加信息(IP地址、证件序列号、会话ID等)
principal 用户的基本信息对象

在我们的示例中,实际上渲染的是principal属性中嵌套的username属性。

当像前面示例那样使用时,<security:authentication>将在视图中渲染属性的值。但是如果你愿意将其赋值给一个变量,那只需要在var属性中指明变量的名字即可。例如,如下展现了如何将其设置给名为loginId的属性:

<security:authentication property="principal.username" var="loginId" />

这个变量默认是定义在页面作用域内的。但是如果你愿意在其他作用域内创建它,例如请求或会话作用域(或者是能够在javax.servlet.jsp.PageContext中获取的其他作用域),那么可以通过scope属性来声明。例如,要在请求作用域内创建这个变量,那可以使用<security:authentication>按照如下的方式来设置:

<security:authentication property="principal.username" 
                              var="loginId" scope="request" />

条件性的渲染内容

有时候视图上的一部分内容需要根据用户被授予了什么权限来确定是否渲染。对于已经登录的用户显示登录表单,或者对还未登录的用户显示个性化的问候信息都是毫无意义的。

Spring Security的<security:authorize>JSP标签能够根据用户被授予的权限有条件地渲染页面的部分内容。例如,在Spittr应用中,对于没有ROLE_SPITTER角色的用户,我们不会为其显示添加新Spitter记录的表单。以下程序展现了如何使用<security:authorize>标签来为具有ROLE_SPITTER角色的用户显示Spitter表单。

<sec:authorize access="hasRole('ROLE_SPITTER')">
    <s:url value="/spittles" var="spittle_url" />
    <sf:form modelAttribute="spittle" action="${spittle_url}">
         <sf:label path="text">
             <s:message code="label.spittle" text="Enter spittle:"/>
         </sf:label>
         <sf:textarea path="text" rows="2" cols="40" /> 
         <sf:errors path="text" />
         <br/>
         <div class="spitIsSubmitIt">
             <input type="submit" value="Spit it!"
                    class="status-btn round-btn disabled" />
         </div>
    </sf:form>
</sec:authorize>

access属性被赋值为一个SpEL表达式,这个表达式的值将确定<security:authorize>标签主体内的内容是否渲染。这里我们使用了hasRole('ROLE_SPITTER')表达式来确保用户具有ROLE_SPITTER角色。但是当你设置access属性时,可以任意发挥SpEL的强大威力。

借助与这些可以的表达式,可以构造出非常有意思的安全性约束。例如,假设应用中有一些管理功能只能对用户名为habuma的用户可用。也许你会像这样使用isAuthenticated()和principal表达式:

<security:authorize
        access="isAuthenticated() and principal.username=='habuma' ">
        <a href="/admin">Administration</a>
</security:authorize>

尽管<security:authorize>能在视图上阻止链接的渲染。但是没能阻止别人在浏览器的地址栏手动输入“/admin”这个URL。

只需要在安全配置中,添加一个对antMatchers()方法的调用将会严格限制对“/admin”这个URL的访问。

.antMatchers("/admin")
    .access("isAuthenticated() and principal.user=='habuma' ");

如果想要不像access属性那样需要在两个地方声明SpEL表达式,可以使用<security:authorize>的url属性,它所要做的事情是对一个给定的URL模式会间接引用其安全性约束。鉴于我们已经在Spring Security配置中为“/admin”声明了安全性约束,所以我们可以这样使用url属性:

<security:authorize url="/admin">
    <spring:url value="/admin" var="admin_url" />
    <br/><a href="${admin_url}">Admin</a>
</security:authorize>

因为只有基本信息中用户名为“habuma”的已认证用户才能访问“/admin” URL,所以只有满足以上条件,<security:authorize>标签主体中的内容才会被渲染。我们只在一个地方配置了表达式(安全配置中),但是在两个地方进行了应用。

上一篇下一篇

猜你喜欢

热点阅读