Spring 学习 保护Web应用
学习内容:
- Spring Security介绍
- 使用Servlet 规范中的Filter保护 Web应用
- 基于数据库和LDAP进行认证
Spring Security简介
Spring Security是为基于Spring的应用程序提供声明式安全保护的安全性框架。 Spring Security 提供了完整的安全性解决方案,它能够在Web请求级别和方法调用级别身份认证和授权。
因为基于Spring框架,所以Spring Security 充分利用了 依赖注入 和 面向切面 的技术。
在Spring Security 3.2版本中,从两个角度来解决安全性问题
- 使用 Servlet 规范中的Filter保护Web请求并限制URL级别的访问
- 使用 Spring AOP 保护方法调用 ———借助于对象代理和使用通知,能够确保只有具备适当权限的用户才能访问安全保护的方法
Spring Security 的模块
不管你想使用Spring Security保护哪种类型的应用程序(这里只讲保护Web应用程序),第一件需要做的就是将Spring Security模块添加到应用程序的类路径下。
它一共有11个模块,如下所示
其中箭头标注的,无论哪种应用都是必须的,Web应用程序还需要添加 Web 模块
过滤Web请求
借助于Spring的小技巧,我们只需配置一共Filter就可以提供各种安全性功能了。
DelegatingFilterProxy 是一个特殊的 Servlet Filter,它本身所做的工作并不多。知识将一个工作委托给一共 javax.servlet.Filter 实现类。这个实现类作为一个\
DelegatingFilterProxy 的配置方法如下
在传统的 web.xml 中配置
注意这里将\
设置成了 springSecurityFilterChain
当我们配置 Spring Security 到 Web 安全性中, 这里会有一个名为 springSecurityFilterChain 的 Filter bean ,DelegatingFilterProxy 会将过滤逻辑委托给它以Java的方式来配置
package spitter.config; import org,springframework.security.web.context.AbstractSecurityWebApplicationInitializer; public class SecurityWebInitializer extends AbstractSecurityWebApplicationInitializer {}
AbstractSecurityWebApplicationInitializer 实现了 WebApplicationInitializer,因此Spring会发现他,并用它在Web容器中注册DelegatingFilterProxy 。
不管是用哪种方式来配置 DelegatingFilterProxy ,它都会拦截发送应用中的请求,并将请求委托给ID 为 springSecurityFilterChain bean .
springSecurityFilterChain 本身是另一个特殊的 Filter,它也被成为 FilterChainProxy. 它可以链接任意一个或多个其他的 Filter. Spring Security 依赖一系列 Servlet Filter 来提供不同的安全特性。
你不需要显示声明 springSecurityFilterChain 以及它所链接在一起的其他Filter。当我们启用 Web 安全性的时候,会自动创建这些Filter。
编写简单的安全性配置
在Spring 3.2之前 安全性配置是很麻烦的
但在 Spring 3.2 之后,引入了新的Java 配置方案,完全不在需要通过XML来配置安全性功能了。
以下是最简单的安全性配置
package spitter.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigureAdapter;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
}
@EnableWebSecurity 注解将会启用 Web 安全功能。但他本身并没有什么用处, Spring Security 必须配置在一个实现了 WebSecurityConfigurer 的 bean中,或者拓展了WebSecurityConfigurerAdapter。 在Spring 应用上下文中,任何实现了 WebSecurityConfigurer 的bean都可以用来配置 Spring Security ,但是最简单的方式还是拓展 WebSecurityConfigurerAdapter 类
@EnableWebSecurity 可以启用任意Web应用的安全性功能,不过,如果你的应用时Spring MVC 那应该考虑使用 @EnableWebMvcSecurity 替代它。 该注解还配置了一个 Spring MVC 参数解析器,这样的话处理器方法就能狗通过带有 @AuthenticationPrincipal 注解的参数获得认证用户的 principal。它同时还配置了一个 bean ,在使用 Spring 表单绑定标签库来定义表单时,这个 bean 会自动添加一个 CSRF token 输入域。
package spitter.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.EnableWebMvcSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigureAdapter;
@Configuration
@EnableWebMvcSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
}
除此之外,我们还可以通过重载 WebSecurityConfigureAdapter 的 configure() 方法来配置 Web 安全性。
当我们没有重写上述三个方法的任何一个时,默认的Filter 即 configure(HttpSecurity) 实际上等同于
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin().and()
.httpBasic()
}
这个默认配置中 authorizeRequests() 和 anyRequest().authenticated() 会要求所有进入应用的HTTP请求都要进行认证。formLogin()使Spring Security支持基于表单的登录 httpBasic() 使应用支持 HTTP Basic方式的认证。
同时,因为我们没有重载 configure(AuthenticationManagerBuilder)方法,所以没有用户存储支撑认证功能。
所以为了让 Spring Security 满足我们应用的需求,还需要添加一点其他配置,具体来讲:
- 配置用户存储 (重载configure(AuthenticationManagerBuilder)方法)
- 指定哪些请求需要认证,哪些请求不需要认证,以及所需要的权限(重载configure(HttpSecurity)方法)
- 提供一个自定义的登录页面,替代原来简单的默认登录页。
选择查询用户详细信息的服务(重载configure(AuthenticationManagerBuilder)方法)
Spring Security 非常灵活,能够基于各种数据存储来认证用户
- 内存
- 关系型数据库
- LDAP
使用基于内存的用户存储
package spitter.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.EnableWebMvcSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigureAdapter;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
@Configuration
@EnableWebMvcSecurity
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");
}
}
- imMemoryAuthentication()指定使用基于内存的用户存储
- withUser()方法为内存用户存储添加新的用户,其用户名为传入其中的字符串
- password()方法为用户指定密码
- roles() 为给定用户授予一个或多个角色权限(它是 authorities() 方法的简写形式,roles()方法所给定的值都会添加一个”ROLE_”前缀,并将其作为权限授予给用户)
基于内存的用户存储,适用于调试和开发人员的测试。
对于生产环境,通常最好将用户数据保存在某种类型的数据库之中。
基于数据库表进行认证
最少配置如下所示
@Autowired
DataSource dataSource;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception{
auth.jdbcAuthentication()
.dataSource(dataSource);
}
第一个查询用于用户认证
第二个查询用于用户授权,查看用户被赋予的角色
第三个查询用于用户授权,查看用户所在用户组所赋予的权限
当默认的查询功能不符合我们需求时,我们就需要设置自己的查询。可以通过如下方式
@Autowired
DataSource dataSource;
@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=?"
);
}
- usersByUsernameQuery() 对应上面第一个查询语句,用于用户认证
- authoritiesByUsernameQuery() 对应上面第二个查询语句,用于用户授权验证
同时还可以指定一个密码转换器,防止黑客窃取用户密码
@Autowired
DataSource dataSource;
@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 StandarPasswordEncoder("53cr3t"))
);
}
passwordEncoder()方法可以接受Spring Security 中 PasswordEncoder 接口的任意实现。
Spring Security 的加密模块包括了三个这样的实现:
- BCryptPasswordEncoder
- NoOpPasswordEncoder
- StandardPasswordEncoder
如果内置的实现无法满足需求,你可以提供自己的实现,
PasswordEncoder 接口定义如下
public interface PasswordEncoder {
String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodedPassword);
}
基于 LDAP 进行认证
简单配置如下
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception{
auth.ldapAuthentication()
.userSearchFilter("{uid={0}}")
.groupSearchFilter("member={0}");
}
- userSearchFilter() 对应上述第一个查询语句,用于用户认证
- groupSearchFilter() 对应上述第三个查询语句,用于用户授权,检查用户所在组被赋予的权限。
当不配置上述两个方法的基础查询时,表名搜索会在LDAP层级结构的根开始。但是我们可以指定查询基础来改变这个默认行为。
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception{
auth.ldapAuthentication()
.userSearcheBase("ou=people")
.userSearchFilter("{uid={0}}")
.groupSearchBase("ou=groups")
.groupSearchFilter("member={0}");
}
配置密码比对
使用 passwordCompare() 方法
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception{
auth.ldapAuthentication()
.userSearcheBase("ou=people")
.userSearchFilter("{uid={0}}")
.groupSearchBase("ou=groups")
.groupSearchFilter("member={0}")
.passwordCompare();
}
默认情况下,在登录表单中提供的密码将会与用户的LDAP条目中的userPassword属性进行比对。如果密码被保存在不同的属性中,可以通过 passwordAttribute() 方法来声明密码属性的名称。同时我们可以通过passwordEncoder()方法对其进行加密
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception{
auth.ldapAuthentication()
.userSearcheBase("ou=people")
.userSearchFilter("{uid={0}}")
.groupSearchBase("ou=groups")
.groupSearchFilter("member={0}")
.passwordCompare()
.passwordEncoder(new Md5PasswordEncoder())
.passwordAttribute("passcode");
}
引用远程的LDAP服务器
可以使用 contextSource()方法来配置远程地址
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception{
auth.ldapAuthentication()
.userSearcheBase("ou=people")
.userSearchFilter("{uid={0}}")
.groupSearchBase("ou=groups")
.groupSearchFilter("member={0}")
.contextSource()
.url("ldap://habuma.com:389/dc=habuma,dc=com");
}
引用嵌入式的LDAP服务器
同样使用 contextSource() 方法来配置
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception{
auth.ldapAuthentication()
.userSearcheBase("ou=people")
.userSearchFilter("{uid={0}}")
.groupSearchBase("ou=groups")
.groupSearchFilter("member={0}")
.contextSource()
.root("classpath:users.ldif");
}
配置自定义的用户服务
当我们使用菲关系型数据库,例如mongodb,redis时,就需要自定义用户服务了。我们需要提供一个自定义的 UserDetailsService 接口实现
UserDetailsService 接口定义如下
public interface UserDetailsService{
Userdetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
我们所需要做的就是实现 loadUserByUsername() 方法,根据给定的用户名来查找用户。
另外一种值得考虑的方案就是修改Spitter,让其实现UserDetails。这样的话,loadUserByUsername()就能直接返回 Spitter 对象了,而不必将它的值复制到 User 对象
在自己实现了一个 UserDetailsService 后,我们就可以使用它了
@Autowired
SpitterRepository spitterRepository;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception{
auth.userDetailsService(new SpitterUserService(spitterRepository));
}
拦截请求(重载configure(HttpSecurity)方法)
在Web应用中,有些请求需要认证,有些请求需要授权,有些请求什么都不需要,这里就需要通过重载 configure(HttpSecurity) 方法来实现了
需要注意的是,在配置时,描述越详细的请求应放在最前面,以防止被覆盖
示例如下
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("spitters/me").authenticated()
.antMatchers(HttpMethod.POST,"spittles").authenticated()
.anyRequest().permitAll();
}
其中
- authorizeRequests() 返回的对象的方法用来配置请求级别的安全性细节
- antMatchers() 支持Ant风格的通配符,用来匹配路径
- regaxMatchers() 支持正则表达式,用法同上
- anyRequest() 表示任意请求
遗憾的是,上面的方法大多都是一维的,也就是说,我们不能在相同的路径上同时使用上面的方法。
幸运的是,我们可以借助 access()方法和 Spring表达式语言(SpEL)来提供多种访问限制
使用Spring表达式进行安全保护
Spring Security 通过一些安全性相关的表达式拓展了Spring 表达式语言,表格如下
用法如下
.antMatchers("spitters/me")
.access("hasRole('ROLE_SPITTER') and hasIpAddress('192.168.1.2')")
强制通道的安全性
下例将强制要求对”/spitter/form”的请求,需要使用HTTPS协议,并自动将请求重定向到HTTPS上
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("spitters/me").authenticated()
.antMatchers(HttpMethod.POST,"spittles").authenticated()
.anyRequest().permitAll()
.and()
.requiresChannel()
.antMatchers("spitter/form").requiresSecure();
}
下例会对”/“的请求,重定向到HTTP上
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("spitters/me").authenticated()
.antMatchers(HttpMethod.POST,"spittles").authenticated()
.anyRequest().permitAll()
.and()
.requiresChannel()
.antMatchers("spitter/form").requiresSecure()
.antMatchers("/").requiresInsecure();
}
防止CSRF攻击
Spring Security3.2开始,默认就会启用 CSRF 防护,它通过一个同步 token方式来实现CSRF防护的功能。
它将会拦截状态变化的请求。并检查 CSRF token。 如果请求中不包含 CSRF Token的话,或者 token 不能与服务器端的token相匹配,请求将会失败,并抛出 CsrfException 异常。
这意味着应用中所有的表单必须在一个”_csrf”域中提交token。
而Spring Security 已经简化了将 token放到请求的属性中这一任务。
以下是两个示例
Thymleaf作为页面模板
<form method="POST" th:action="@{/spittles}"> ... </form>
JSP作为页面模板
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />
我们也可以禁用 CSRF 防护(不推荐)
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
}
认证用户
提供自定义认证表单
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.loginPage("/login")
......
}
对指定域启用HTTP Basic 认证
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.loginPage("/login")
.and()
.httpBasic()
.realmName("Spittr")
.and()
......
}
启用Remember-me功能
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.loginPage("/login")
.and()
.rememberMe()
.tokenValiditySeconds(2419200)
.key("spittrKey")
......
}
退出
protected void configure(HttpSecurity http) throws Exception {
http
.formLogin()
.loginPage("/login")
.and()
.logout()
.logoutSuccessUrl("/")
.logoutUrl("/signout") //不设置时,默认登出路径为"/logout"
......
}
保护方法应用
上面的方法是用于保护应用的 Web 层,这种保护是比较笼统的,你不能执行更细粒度的保护。 例如当出现了一个 URL ,管理员和普通用户都能访问,但管理员可以查看所有用户的信息,但普通用户只能查看自己的信息时,上述保护 Web 层的方法就显得有点鸡肋了,因为如果你想实现这种需求,运用上述方法,你只能写两个不同的方法来分别响应不同的请求了。
所以现在我们学习应该如何保护场景后面的方法,这样就能保证如果用户不具备权限的话,就无法执行相应的逻辑了,上述的例子你就可以将其合并为一个方法。
Spring Security 提供了三种不同的安全注解:
- Spring Security 自带的 @Secured 注解
- JSR-250 的 @RolesAllowed 注解
- 表达式驱动的注解,包括@PrsAuthorize、@PostAuthorize、@PreFilter、@PostFilter
@Secured 和 @RolesAllowed 方案非常类似,能够基于用户所授予的权限限制对方法的访问。当我们需要在方法上定义更灵活的安全规则时, Spring Security 提供了@PreAuthorize 和 @PostAuthorize 而 @PreFilter 、 @PostFilter 能够过滤方法返回的以及传入方法的集合
使用 @Secured 注解限制方法调用
在 Spring 中,如果要启用基于注解的方法安全性,关键之处在于要在配置类上使用 @EnableGlobalMethodSecurity。
例子如下所示
@Configuration
@EnableGlobalMethodSecurity(securedEnabled=true)
public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration {
}
注意
- @EnableGlobalMethodSecurity 启用的是基于注解的方法安全性
- @EnableGlobalMethodSecurity 中的securedEnabled属性的值为 true 时,将会创建一个切点,这样的话 Spring Security 切面就会包装带有 @Secured 注解的方法。
- GlobalMethodSecurityConfiguration 类能够为方法级别的安全性提供更精细的配置
- @EnableWebSecurity 启用的是基于注解的Web安全性
- WebSecurityConfigurerAdapter 类能够为 Web级别的安全性提供配置
同样的,我们可以通过重载 GlobalMethodSecurityConfiguration 的 configure() 方法来实现 Web层的安全配置中设置认证:
@Configuration
@EnableGlobalMethodSecurity(securedEnabled=true)
public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception{
auth.inMemoryAuthentication()
.withUser("user").password("password").roles("USER").and()
.withUser("admin").password("password").roles("USER","ADMIN");
}
}
我们还可以重载 createExpressionHandler() 方法,提供一些自定义的安全表达式处理行为
然后我们就可以在控制器中使用 @Secured 注解了
@Secured({"ROLE_SPITTER","ROLE_ADMIN"})
public void addSpittle(Spittle spittle) {
}
如果方法被没有认证的用户或没有所需权限的用户调用,保护这个方法的切面将抛出一个 Spring Security 异常( AuthenticationException 或 AccessDeniedException)。它们是非检查型异常,但这个异常最终必须要被捕获和处理。如果被保护的方法是在 Web 请求中调用的,这个异常会被 Spring Security 的过滤器自动处理。否则的话,你需要编写代码来处理这个异常.
使用 JSR-250 的 @RolesAllowed 注解
使用@RoleAllowed 注解 和 @Secured 注解 在各个方面基本上都是一直的。唯一显著的区别在于 @RolesAllowed 注解 是 JSR-250 定义的Java 标准注解。 当使用其他框架(非 Spring)时,@RolesAllowed将更会有意义。
我们先实现配置类
@Configuration
@EnableGlobalMethodSecurity(jsr250Enabled=true)
public class MathodSecurityConfig extends GlobalMethodSecurityConfiguration {
}
- 当 jsr250Enabled 属性设置为 true 之后,将会启用一个切点,这样带有 @RolesAllowed 注解的方法都会被 Spring Security 的切面包装起来。
接下来我们就可以使用它
@RolesAllowed("ROLE_SPITTER")
public void addSpittle(Spittle spittle) {
}
无论是 @RolesAllowed 注解还是 @Secured 注解。它们都有一个共同的不足。它们只能根据用户有没有授予特定的权限来限制方法的调用。在判断方法是否执行方面,无法使用其他的因素,接下来我们看一下如何使用 SpEL 与 Spring Security 所提供的方法调用前后注解,实现基于表达式的方法安全性。
使用表达式实现方法级别的安全性
Spring Security 3.0 引入了几个新注解,这些注解的值参数中都可以接受一个 SpEL 表达式。表达式可以是任意合法的 SpEL 表达式。如果表达式的计算结果为 true ,那么安全规则通过,否则就会失败。安全规则通过或失败的结果会因为所使用注解的差异而有所不同。
同样。我们首先应该实现配置类
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled=true)
public class MethodSecurityConfig extends GlobalMethodSecurityConfiguration{
}
- 当prePostEnabled 设置为 true 时 ,它会创建一个切点,这样的话 Spring Security 切面就会包装带有上述4个注解的方法了
表述方法访问规则
使用以下两个注解
@PreAuthorize
表达式会在方法调用之前执行,如果表达式的计算结果不为 true 的话,将会阻止方法执行@PostAuthorize
表达式会在方法调用之后执行,如果表达式的计算结果不为 true 的话,将会抛出安全性的异常
它们两个比 @Secured 和 @RolesAllowed 更灵活
@PreAuthorize 使用示例
以下是例子,它会检查字数,如果是普通用户那么字数应该在140字以内,如果是付费用户,则不限制字数
@PreAuthorize( "(hasRole('ROLE_SPITTER') and #spittle.text.length()<= 140>)" + "or hasRole('ROLE_PREMIUM')") public void addSpittle(Spittle spittle) { }
表达式中的 #spittle 部分直接引用了方法中的同名参数
@PostAuthorize
@PostAuthorize("returnObject.spitter.username == principal.username") public Spittle getSpittleById(long id) { }
为了遍历的访问受保护方法的返回对象 Spring Security 在 SpEL 中提供了名为 returnObject 的变量。
而 principal 是另一个 Spring Security 内置的特殊名称,它代表了当前认证用户的主要信息
过滤方法的输入与输出
使用以下两个注解
@PreFilter
该注解会过滤传入方法的参数(过滤输入)
下例,能保证方法的列表中只包含当前用户有权限删除的 Spittle@PreAuthorize( "(hasRole({'ROLE_SPITTER','ROLE)ADMIN'})") @PreFilter( "hasRole('ROLE_ADMIN') || " + 'targetObject.spitter.username == principal.name') public void deleteSpittle(List<Spittle> spittles) { ... }
targetObject 是 Spring security 提供的另外一个值,它代表了要进行计算的当前列表元素
@PostFilter
该注解会过滤方法返回的值(过滤输出)@PreAuthorize( "(hasRole({'ROLE_SPITTER','ROLE)ADMIN'})") @PostFilter( "hasRole('ROLE_ADMIN') || " + 'filterObject.spitter.username == principal.name') public List<Spittle> getoffensiveSpittles() { ... }
FilterObject 是 Spring security 提供的另外一个值,它代表了这个方法所返回的列表中的某一个元素
上面的两个注释对于安全规则的设置很灵活,但当安全规则的逻辑很复杂时,并不推荐直接写在注释里,一是可读性不好,二是其测试困难,所以此时我们会选择定义许可计算器来解决问题
定义许可计算器
使用 hasPermission()
hasPermission() 函数是 Spring Security 为SpEL提供的拓展,它为开发者提供了一个世纪能够在执行计算的时候插入任意的逻辑。我们所需要做的就是编写并注册一个自定义的许可计算器。
为此,我们需要实现 Spring Security 的 PermissionEvaluator 接口,该接口需要实现两个不用的 hasPermission() 方法。
- 其中的一个 hasPermission() 方法把要评估的对象作为第二个参数
- 第二个 hasPermission() 方法在只有目标对象的 ID 可以得到的时候才有用,并将 ID 作为 Serializable 传入第二个参数
下面是一个示例
我们就可以使用
@PreAuthorize("hasAnyRole({'ROLE_SPITTER','ROLE_ADMIN'})")
@PreFilter("hasPermission(targetObject,'delete')")
public void deleteSpittles(List<Spittle> spittles) { ... }