初识Spring Security
Spring Security简介
应用程序的安全性通常体现在两个方面:认证和授权。
1、认证:是确认某主体在某系统中是否合法、可用的过程。这里的主体既可以是登录系统的用户,也可以是接入的设备或者其他系统。
2、授权:指当主题通过认证之后,是否允许其执行某项操作的过程。
Spring Security已经集成的认证技术:
- HTTP BASIC authentication headers:一个基于IETF RFC的标准;
- HTTP Digest authentication headers:一个基于IETF RFC的标准;
- HTTP X.509 client certificate excahnge:一个基于IETF RFC的标准;
- LDAP:一种常见的跨平台身份验证方式;
- Form-based authentication:用于简单的用户界面需求;
- OpenID authentication:一种去中心化的身份认证方式;
- Authentication based on pre-established request headers:类似于Computer Associates SiteMinder,一种用户身份验证及授权的集中式安全基础方案;
- Jasig Central Authentication Service:单点登录方案;
- Transparent authentication context propagation for Remote Method Invocation (RMI) and HttpInvoker:一个Spring远程调用协议;
- Automatic “remember-me” authentication:允许在指定到期时间前自行重新登录系统;
- Anonymous authentication:允许匿名用户使用特定的身份安全访问资源;
- Run-as authentication:允许在一个会话中变换用户身份的机制;
- Java Authentication and Authorization Service:JAAS,Java验证和授权API;
- Java EE container authentication:允许系统继续使用容器管理这种身份验证方式;
- Kerberos:一种使用对称密钥机制,允许客户端与服务器相互确认身份的认证协议。
除此之外,Spring Security还引入了一些第三方包,用于支持更多的认证技术,如JOSSO等。如果所有这些技术都无法满足需求,则Spring Security允许我们编写自己的认证技术。因此,在绝大部分情况下,当我们有Java应用安全方面的需求时,选择Spring Security往往是正确而有效的。
在授权上,Spring Security不仅支持基于URL对Web的请求授权,还支持方法访问授权、对象访问授权等,基本涵盖常见的大部分授权场景。
创建一个简单的Spring Security项目
1 | <dependency> |
点进spring-boot-starter-security,可以看到其依赖
1 | <dependencies> |
简单测试——默认生效的HTTP基本认证
1 | package com.louris.springboot; |
进入localhost:8080
后跳出登录页面验证,默认用户名为user, 密码为动态生成的,需要查看后台控制台信息。验证通过后才进入页面。
可以通过配置文件设置:
1 | spring.security.user.name=user |
表单认证
默认表单认证
自定义WebSecurityConfiguerAdapter类
1 | package com.louris.springboot.config; |
其中WebSecurityConfiguerAdapter类中的方法已经声明了一些安全特性:
- 验证所有请求;
- 允许用户使用表单登录进行身份验证(Spring Security提供了一个简单的表单登录页面);
- 允许用户使用HTTP基本认证;
1
2
3
4
5
6
7
8protected void configure(HttpSecurity http) throws Exception {
this.logger.debug("Using default configure(HttpSecurity). If subclassed this will potentially override subclass configure(HttpSecurity).");
http.authorizeRequests((requests) -> {
((AuthorizedUrl)requests.anyRequest()).authenticated();
});
http.formLogin();
http.httpBasic();
}
重新刷新的话,直接进入页面了,没有表单验证,需要清理缓存。
自定义表单登录页
初步配置自定义表单登录页
1 | package com.louris.springboot.config; |
将页面放在resources/static
下
1 |
|
其他表单配置项
1 | package com.louris.springboot.config; |
认证与授权
默认数据库模型的认证与授权
资源准备
1 | package com.louris.springboot.controller; |
1 | package com.louris.springboot.controller; |
1 | package com.louris.springboot.controller; |
资源授权的配置
- antMatchers()是一个采用ANT模式的URL匹配器;
- ANT模式使用
?
匹配任意单个字符,使用*
匹配0或任意数量的字符,使用**
匹配0或者更多的目录 antMatchers("/admin/api/**")
相当于匹配了/admin/api
下所有API,此处我们指定当其必须为ADMIN
角色时才能访问,其他同理;/app/api
下的API会调用permitAll()
公开其权限1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20package com.louris.springboot.config;
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;
//里面已经加入了@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
protected void configure(HttpSecurity http) throws Exception {
//指定登录成功时的处理逻辑
http.authorizeRequests()
.antMatchers("/admin/api/**").hasRole("ADMIN")
.antMatchers("/user/api/**").hasRole("USER")
.antMatchers("/app/api/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin();
}
}
启动运行后,可以发现:
localhost:8080/app/api/hello
可以正常访问- 其他都是403未授权,需要验证用户
基于内存的多用户支持
配置类以及密码解析器
1 | package com.louris.springboot.config; |
1 | package com.louris.springboot.bean; |
控制器
1 | package com.louris.springboot.controller; |
1 | package com.louris.springboot.controller; |
1 | package com.louris.springboot.controller; |
基于默认数据库模型的认证与授权
1 | create table users( |
1 | <dependency> |
1 |
|
createUser
函数相当于执行SQL语句insert into users(username, password, enabled) values(?,?,?)
;
启动后,可以看到数据库中已经插入了对应数据,authorities
表的authority字段存放的是前面设定的角色,只是会被添加上”ROLE_”前缀。
或者重写重载函数
1 |
|
自定义数据库模型的认证与授权
实现UserDetails
系统内的相关接口
1 | // |
1 | // |
数据库准备
1 | create table users( |
数据库POJO类
1 | package com.louris.springboot.bean; |
实现UserDetails定义的几个方法:
- isAccountNonExpired、isAccountNonLocked和isCredentialsNonExpired暂且用不到,统一返回true,否则Spring Security会认为账号异常;
- isEnabled对应enable字段,将其带入即可;
- getAuthorities方法本身对应的是roles字段,但由于结构不一致,所以此处新建一个,并在后续进行填充。
数据库接口
1 | package com.louris.springboot.mapper; |
接口实现类
1 | package com.louris.springboot.impl; |
实现图形验证码
在验证用户名和密码之前,引入辅助验证可有效防范暴力试错,图形验证码就是简单且行之有效的一种辅助验证方式。
使用过滤器实现图形验证码
验证码(CAPTCHA)的全称是Completely Automated Public Turing test to tell Computers and Humans Apart,翻译过来就是“全自动区分计算机和人类的图灵测试”。通俗地讲,验证码就是为了防止恶意用户暴力重试而设置的。不管是用户注册、用户登录,还是论坛发帖,如果不加以限制,一旦某些恶意用户利用计算机发起无限重试,就很容易使系统遭受破坏。
自定义过滤器
在Spring Security中,实现验证码校验的方式有很多种,最简单的方式就是自定义一个专门处理验证码逻辑的过滤器,将其添加到Spring Security过滤器链的合适位置。当匹配到登录请求时,立刻对验证码进行校验,成功则放行,失败则提前结束整个验证请求。
1 |
|
HttpSecurity实际上就是在配置Spring Security的过滤器链,诸如CSRF、CORS、表单登录等,每个配置器对应一个过滤器。我们可以通过HttpSecurity配置过滤器的行为,甚至可以向CRSF一样直接关闭过滤器。例如,sessionManagement:
1 | public SessionManagementConfigurer<HttpSecurity> sessionManagement() throws Exception { |
Spring Security通过SessionManagementConfiguerer来配置SessionManagement的行为。与SessionManagementConfigurer类似的配置器还有CorsConfiguer、RememberMeConfigurer等,它们都实现了SecurityConfigurer的标准接口。
1 | public interface SecurityConfigurer<O, B extends SecurityBuilder<O>> { |
图形验证码过滤器
添加依赖
1 | <dependency> |
控制器
1 | package com.louris.springboot.controller; |
自定义认证异常类以及处理器
1 | package com.louris.springboot.exception; |
1 | package com.louris.springboot.bean; |
自定义过滤器
1 | package com.louris.springboot.filter; |
页面逻辑
1 |
|
配置过滤链以及图形验证码Bean
1 | package com.louris.springboot.config; |
使用自定义认证实现图形验证码
上一节使用过滤器的方式实现了带图形验证码的验证功能,属于Servlet层面,简单、易理解。其实,Spring Security还提供了一种更优雅的实现图形验证码的方式,即自定义认证。
认识AuthenticationProvider
- 系统中的用户,在Spring Security中被称为主体(principal)。主体包含了所有能够经过验证而获得系统访问权限的用户、设备或其他系统。
- Authentication中包含主体权限列表、主体凭据、主体详细信息,以及主体是否验证成功等信息。
1 | // |
- 由于大部分场景下身份验证都是基于用户名和密码进行的,所以Spirng Security提供了一个UsernamePasswordAuthenticationToken用于指代这一类证明(例如,用SSH KEY也可以登录,但它不属于用户名和密码登录这个范畴,如有必要,也可以自定义提供)。
1 | // |
- 在之前使用的表单登录中,每一个登录用户都被包装为一个UsernamePasswordAuthenticationToken,从而在Spring Security的各个AuthenticationProvider中流动。
1 | // |
- 一个完整的认证可以包含多个AuthenticationProvider,一般由ProviderManager管理
1 | // |
自定义AuthenticationProvider
Spring Security提供了多种常见的认证技术,包括但不限于以下几种:
- HTTP层面的认证技术,包括HTTP基本认证和HTTP摘要认证两种;
- 基于LDAP的认证技术(Lightweight Directory Access Protocol,轻量目录访问协议);
- 聚焦于证明用户身份的OpenID认证技术;
- 聚焦于授权的OAuth认证技术;
- 系统内维护的用户名和密码认证技术
原理
使用最为广泛的是由系统维护的用户名和密码认证技术,通常会设计数据库访问。为了更好地按需定制,Spring Security并没有直接糅合整个认证过程,而是提供了一个抽象的AuthenticationProvider。
1 | // |
- Spring Security同样提供一个继承自AbstractUserDetailsAuthenticatioonProvider的AuthenticationProvider;
- DaoAuthenticationProvider的用户信息来源于UserDetailsService,并且整合了密码编码的实现,在之前章节中的表单认证就是由DapAuthenticationProvider提供的。
1 | // |
自定义AutehnticationProvider
1 | package com.louris.springboot.bean; |
实现图形验证码的AuthenticationProvider
原理
1 | // |
1 | // |
1 | // |
自定义验证码AuthenticationProvider
CaptchaAuthenticationProvider
1 | package com.louris.springboot.bean; |
WebAuthenticationDetails
1 | package com.louris.springboot.bean; |
WebAuthenticationDetailsSource
1 | package com.louris.springboot.bean; |
WebSecurityConfig
1 | package com.louris.springboot.config; |
自动登录和注销登录
为什么需要自动登录
为了尽可能减少用户重新登录的频率,在系统开发之初就需要考虑加入可以提升用户登录体验的功能。自动登录便是这样一个会给用户带来便利,同时也会给用户带来风险的体验性功能。
自动登录是将用户登录信息保存在用户浏览器的cookie中,当用户下次访问时,自动实现校验并建立登录态的一种机制。
Spring Security提供了两种非常好的令牌
- 用散列算法加密用户必要的登录信息并生成令牌;
- 数据库等持久性数据存储机制用的持久化令牌
散列算法在Spring Security中是通过加密几个关键信息实现的。
1 | hashInfo = md5Hex(username + ":" + expirationTime + ":" + password + ":" + key) |
其中,expirationTime
指本次自动登录的有效期,key
为指定的一个散列盐值,用于防止令牌被修改。通过这种方式生成cookie
后,在下次登录时,Spring Security首先用Base64
简单解码得到用户名、过期时间和加密散列值;然后使用用户名得到密码;接着重新以该散列算法正向计算,并将计算结果与旧的加密散列值进行对比,从而确认该令牌是否有效。
实现自动登录
散列加密方案
实现
- 默认情况下,过期时间为两周
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68package com.louris.springboot.config;
import com.google.code.kaptcha.Producer;
import com.google.code.kaptcha.impl.DefaultKaptcha;
import com.google.code.kaptcha.util.Config;
import com.louris.springboot.bean.MyAuthenticationFailureHandler;
import com.louris.springboot.filter.VerificationCodeFilter;
import com.louris.springboot.impl.MyUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.AuthenticationDetailsSource;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.AbstractUserDetailsAuthenticationProvider;
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;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.WebAuthenticationDetails;
import org.springframework.security.web.authentication.session.SessionAuthenticationException;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.security.web.session.SessionManagementFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.sql.DataSource;
import java.util.Properties;
//里面已经加入了@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private MyUserDetailsService userDetailsService;
protected void configure(HttpSecurity http) throws Exception {
//指定登录成功时的处理逻辑
http.authorizeRequests()
.antMatchers("/admin/api/**").hasRole("ADMIN")
.antMatchers("/user/api/**").hasRole("USER")
//开放captcha.jpg的访问权限
.antMatchers("/app/api/**").permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable()
.formLogin()
.and()
//增加自动登录功能,默认为简单散列加密
.rememberMe().userDetailsService(userDetailsService);
}
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("user").password("123456").roles("USER")
.and()
.withUser("admin").password("123456").roles("ADMIN", "USER");
}
}
原理
1 | public abstract class AbstractRememberMeServices implements RememberMeServices, InitializingBean, LogoutHandler, MessageSourceAware { |
1 | // |
其中,在没有指定时,key是一个UUID字符串
1 | private String getKey(){ |
这将导致每次重启服务后,key都会重新生成,使得重启之前的所有自动登录cookie失效。除此之外,在多实例部署的情况下,由于实例间的key并不相同,所以当用户访问系统的另一个实例时,自动登录策略就会失效。合理的用法是指定key。
1 |
|
- 总体来说,这种方式不需要服务器花费空间来存储自动登录的相关数据,实现简单,安全性相对较高
- 但存在潜在风险,即如果该令牌在有效期内被盗取,那么用户的身份将完全暴露
持久化令牌方案
原理
- 持久化令牌方案在交互上与散列加密方式一致,都是在用户勾选Remember-me之后,将生成的令牌发送到用户浏览器,并在用户下次访问系统时读取该令牌进行认证。不同的是,它采用了更加严谨的安全性设计。
- 在持久化令牌方案中,最核心的是
series
和token
两个值,它们都是用MD5散列过的随机字符串。不同的是,series
仅在用户使用密码重新登录时更新,而token会在每一个新的session中都重新生成。
这样设计的好处:
- 首先,解决了散列加密方案中一个令牌可以同时在多端登录的问题。每个会话都会引发token的更新,即每个token仅支持单实例登录;
- 其次,自动登录不会导致
series
变更,而每次自动登录都需要同时验证series
和token
两个值,当该令牌还未使用过自动登录就被盗取时,系统会在非法用户验证通过后刷新token
值,此时在合法用户的浏览器汇总,该token
值已经失效。当合法用户使用自动登录时,由于该series
对应的token
不同,系统可以推断该令牌可能已被盗用,从而做一些处理。例如,清理该用户的所有自动登录令牌,并通知用户可能已被盗号等。
Spring Security使用PersistentRememberMeToken来表明一个验证实体
1 | // |
需要传入一个PersistentTokenRepository实例,PersistentTokenRepository实例定义了持久化令牌的一些必要方法:
1 | // |
既可以按照自己的方式实现PersistentTokenRepository接口,也可以使用Spring Security提供的JDBC方案实现
1 |
具体通过实现类实现:
1 | // |
实现
对应的,需要在数据库中新建一张persistent_logins
表
1 | create table persistent_logins( |
1 | package com.louris.springboot.config; |
注销登录
认证系统往往都带有注销登录功能,Spring Security也提供了这方面的支持。事实上,从我们编写配置类继承WebSecurityConfigurerAdapter的那一刻起,Spring Security就已经为我们的系统埋入了注销的逻辑。
默认实现
默认注册了一个/logout
路由,用户通过访问该路由可以安全地注销其登录状态,包括使HttpSession失效、清空已配置的Remember-me验证,以及清空SecurityContextHolder,并在注销成功之后重定向到/login?logout
页面。
如有必要,还可以重新配置。
自定义实现
1 | package com.louris.springboot.config; |
原理
实际上,logout的清理过程是由多个LogoutHandler流式处理的。
1 | // |
在Logout过滤器中可以理顺整个注销的处理流程。
1 | // |
1 | // |
会话管理
理解会话
会话(session)就是无状态的HTTP实现用户状态可维持的一种解决方案。HTTP本身的无状态 使得用户在与服务器的交互过程中,每个请求之间都没有关联性。这意味着用户的访问没有身份记录,站点也无法为用户提供个性化的服务。
session的诞生解决了这个难题,服务器通过与用户约定每个请求携带一个id类的信息,从而让不同请求之间有了关联,而id又可以很方便地绑定具体用户,所以我们可以把不同请求归类到同一用户。
- 基于这个方案,为了让用户每个请求都携带同一个id,在不妨碍体验的情况下,cookie是很好的载体。当用户首次访问系统时,系统会为该用户生成一个sessionId,并添加到cookie中。在该用户的会话期内,每个请求都自动携带该cookie,因此系统可以很轻易地识别出这是来自哪个用户的请求。
- 尽管cookie非常有用,但有时用户会在浏览器中禁用它,可能是出于安全考虑,也可能是为了保护个人隐私。在这种情况下,基于cookie实现的sessionId自然就无法正常使用了。
- 因此,有些服务还支持用URL重写的方式来实现类似的体验,例如
http://blurooo.com;jsessionid=xxx
,URL重写原本是为了兼容禁用cookie的浏览器而设计的,但也容易被黑客利用。黑客只需访问一次系统,将系统生成的sessionId提取并拼凑在URL上,然后将该URL发给一些取得信任的用户。只要用户在session有效期内通过此URL进行登录,该sessionid就会绑定到用户的身份,黑客便可以轻松享有同样的会话状态,完全不需要用户名和密码,这就是典型的会话固定攻击。
防御会话固定攻击
防御会话固定攻击的方法非常简单,只需在用户登录之后重新生成新的session即可。在集成WebSecurityConfigurerAdapter
时,Spring Security已经启用了该配置。
1 | protected final HttpSecurity getHttp() throws Exception { |
1 | private void applyDefaultConfiguration(HttpSecurity http) throws Exception { |
sessionManagement是一个会话管理的配置器,其中,防御会话固定攻击的策略有四种:
- none:不做任何变动,登录之后沿用旧的session;
- newSession:登录之后创建一个新的session;
- migrateSession:登录之后创建一个新的session,并将旧的session中的数据复制过来;
- changeSessionId:不创建新的会话,而是使用由Servlet容器提供的会话固定保护。
默认已经启用migrateSession
策略,如有必要,可以做出修改:
1 |
|
在Spring Security中,即便没有配置,也大可不必担心会话固定攻击。这是因为Spring Security的HTTP防火墙会帮助我们拦截不合法的URL,当我们试图访问带session的URL时,实际上会被重定向到错误页。localhost:8080/user/api/hello;jsessionId=xxx
会话过期
除防御会话固定攻击外,还可以通过Spring Security配置一些会话过期策略。
会话过期URL
例如,会话过期时跳转到某个URL。
1 |
|
自定义会话过期策略
或者完全自定义过期策略
1 |
|
会话过期时间
默认情况下,会话过期时间为30分钟;
修改过期时间
1 | # 单位为秒 |
- 会话的过期时间最少为1分钟:
TomcatServletWebServerFactory
中
1 | private long getSessionTimeoutInMinutes() { |
会话并发控制
固定会话攻击和会话过期策略都很简单,在Spring Security中,会话管理最完善的是会话并发控制,但会话并发控制存在一些用法陷阱,应当多加注意。
基于内存的并发控制
简单控制会话并发数配置
1 |
|
清理过期或已注销的会话
- 上述的Spring Security的会话并发控制如此简单,但是如果我们首次尝试将已登录的旧会话注销(通常是访问
/logout
),理论上应该可以继续登录了,但很遗憾,Spring Security依然提示我们超过了最大会话数。 - 事实上,除非重启服务,否则该用户将很难再次登录系统。这是因为Spring Security是通过监听session的销毁事件来触发会话信息表相关清理工作的,但是我们并没有注册过相关的监听器,导致Spring Security无法正常清理过期或已注销的会话。
1 |
|
基于数据库的会话并发控制
需要在User类中重写equals
和hashCode
方法,理由稍后。
1 | package com.louris.springboot.bean; |
1 | package com.louris.springboot.impl; |
原理
在Servlet中,监听session相关事件的方法是实现HttpSessionListener接口,并在系统中注册该监听器。Spring Security在HttpSessionEventPublisher类中实现HttpSessionEventPublisher接口,并转化成Spring的事件机制。
1 | // |
1 | // |
- 注意,principals采用了以用户信息为key的设计。在hashMap中,以对象为key必须重写hashCode和equals两个方法,所以需要在自定义实现的UserDetails时重写,否则将导致同一个用户每次登陆注销时计算得到的key都不相同,所以每次登陆都会向principals中添加一个用户,而注销时却从来不能有效移除。在这种情况下,不仅达不到会话并发控制的效果,还会引发内存泄漏。
- 而基于内存的用户配置不会触发陷阱,因为其沿用了Spring Security内部实现的UserDetails,自然不会出现问题。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package org.springframework.security.core.userdetails;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.function.Function;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.security.core.CredentialsContainer;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.util.Assert;
public class User implements UserDetails, CredentialsContainer {
private static final long serialVersionUID = 550L;
private static final Log logger = LogFactory.getLog(User.class);
private String password;
private final String username;
private final Set<GrantedAuthority> authorities;
private final boolean accountNonExpired;
private final boolean accountNonLocked;
private final boolean credentialsNonExpired;
private final boolean enabled;
public User(String username, String password, Collection<? extends GrantedAuthority> authorities) {
this(username, password, true, true, true, true, authorities);
}
public User(String username, String password, boolean enabled, boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) {
Assert.isTrue(username != null && !"".equals(username) && password != null, "Cannot pass null or empty values to constructor");
this.username = username;
this.password = password;
this.enabled = enabled;
this.accountNonExpired = accountNonExpired;
this.credentialsNonExpired = credentialsNonExpired;
this.accountNonLocked = accountNonLocked;
this.authorities = Collections.unmodifiableSet(sortAuthorities(authorities));
}
public Collection<GrantedAuthority> getAuthorities() {
return this.authorities;
}
public String getPassword() {
return this.password;
}
public String getUsername() {
return this.username;
}
public boolean isEnabled() {
return this.enabled;
}
public boolean isAccountNonExpired() {
return this.accountNonExpired;
}
public boolean isAccountNonLocked() {
return this.accountNonLocked;
}
public boolean isCredentialsNonExpired() {
return this.credentialsNonExpired;
}
public void eraseCredentials() {
this.password = null;
}
private static SortedSet<GrantedAuthority> sortAuthorities(Collection<? extends GrantedAuthority> authorities) {
Assert.notNull(authorities, "Cannot pass a null GrantedAuthority collection");
SortedSet<GrantedAuthority> sortedAuthorities = new TreeSet(new User.AuthorityComparator());
Iterator var2 = authorities.iterator();
while(var2.hasNext()) {
GrantedAuthority grantedAuthority = (GrantedAuthority)var2.next();
Assert.notNull(grantedAuthority, "GrantedAuthority list cannot contain any null elements");
sortedAuthorities.add(grantedAuthority);
}
return sortedAuthorities;
}
public boolean equals(Object obj) {
return obj instanceof User ? this.username.equals(((User)obj).username) : false;
}
public int hashCode() {
return this.username.hashCode();
}
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append(this.getClass().getName()).append(" [");
sb.append("Username=").append(this.username).append(", ");
sb.append("Password=[PROTECTED], ");
sb.append("Enabled=").append(this.enabled).append(", ");
sb.append("AccountNonExpired=").append(this.accountNonExpired).append(", ");
sb.append("credentialsNonExpired=").append(this.credentialsNonExpired).append(", ");
sb.append("AccountNonLocked=").append(this.accountNonLocked).append(", ");
sb.append("Granted Authorities=").append(this.authorities).append("]");
return sb.toString();
}
public static User.UserBuilder withUsername(String username) {
return builder().username(username);
}
public static User.UserBuilder builder() {
return new User.UserBuilder();
}
/** @deprecated */
public static User.UserBuilder withDefaultPasswordEncoder() {
logger.warn("User.withDefaultPasswordEncoder() is considered unsafe for production and is only intended for sample applications.");
PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
User.UserBuilder var10000 = builder();
Objects.requireNonNull(encoder);
return var10000.passwordEncoder(encoder::encode);
}
public static User.UserBuilder withUserDetails(UserDetails userDetails) {
return withUsername(userDetails.getUsername()).password(userDetails.getPassword()).accountExpired(!userDetails.isAccountNonExpired()).accountLocked(!userDetails.isAccountNonLocked()).authorities(userDetails.getAuthorities()).credentialsExpired(!userDetails.isCredentialsNonExpired()).disabled(!userDetails.isEnabled());
}
public static final class UserBuilder {
private String username;
private String password;
private List<GrantedAuthority> authorities;
private boolean accountExpired;
private boolean accountLocked;
private boolean credentialsExpired;
private boolean disabled;
private Function<String, String> passwordEncoder;
private UserBuilder() {
this.passwordEncoder = (password) -> {
return password;
};
}
public User.UserBuilder username(String username) {
Assert.notNull(username, "username cannot be null");
this.username = username;
return this;
}
public User.UserBuilder password(String password) {
Assert.notNull(password, "password cannot be null");
this.password = password;
return this;
}
public User.UserBuilder passwordEncoder(Function<String, String> encoder) {
Assert.notNull(encoder, "encoder cannot be null");
this.passwordEncoder = encoder;
return this;
}
public User.UserBuilder roles(String... roles) {
List<GrantedAuthority> authorities = new ArrayList(roles.length);
String[] var3 = roles;
int var4 = roles.length;
for(int var5 = 0; var5 < var4; ++var5) {
String role = var3[var5];
Assert.isTrue(!role.startsWith("ROLE_"), () -> {
return role + " cannot start with ROLE_ (it is automatically added)";
});
authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
}
return this.authorities((Collection)authorities);
}
public User.UserBuilder authorities(GrantedAuthority... authorities) {
return this.authorities((Collection)Arrays.asList(authorities));
}
public User.UserBuilder authorities(Collection<? extends GrantedAuthority> authorities) {
this.authorities = new ArrayList(authorities);
return this;
}
public User.UserBuilder authorities(String... authorities) {
return this.authorities((Collection)AuthorityUtils.createAuthorityList(authorities));
}
public User.UserBuilder accountExpired(boolean accountExpired) {
this.accountExpired = accountExpired;
return this;
}
public User.UserBuilder accountLocked(boolean accountLocked) {
this.accountLocked = accountLocked;
return this;
}
public User.UserBuilder credentialsExpired(boolean credentialsExpired) {
this.credentialsExpired = credentialsExpired;
return this;
}
public User.UserBuilder disabled(boolean disabled) {
this.disabled = disabled;
return this;
}
public UserDetails build() {
String encodedPassword = (String)this.passwordEncoder.apply(this.password);
return new User(this.username, encodedPassword, !this.disabled, !this.accountExpired, !this.credentialsExpired, !this.accountLocked, this.authorities);
}
}
private static class AuthorityComparator implements Comparator<GrantedAuthority>, Serializable {
private static final long serialVersionUID = 550L;
private AuthorityComparator() {
}
public int compare(GrantedAuthority g1, GrantedAuthority g2) {
if (g2.getAuthority() == null) {
return -1;
} else {
return g1.getAuthority() == null ? 1 : g1.getAuthority().compareTo(g2.getAuthority());
}
}
}
}
集群会话的缺陷
会话通常保存在服务器内存中,客户端访问时根据自己的sessionId在内存中查找,这种方法虽然简单快捷,但缺点也很明显。从容量上来说,服务器内存有限,除了系统正常运行的消耗,留给session的空间不多,当访问量增大时,内存就会捉襟见肘。从稳定性上来说,session依赖于内存,而内存并非持久性存储容器,就算服务器本身是可靠的,但当部署在上面的服务停止或重启时,也会导致所有会话状态丢失。当然,这两个缺点还只是体验性缺陷,并不足以影响可用性,在单机部署时为了节省精力忽略这个问题也是可以的。但当我们的同此阿勇集群部署时,就会有更多关于可用性的问题需要考虑。
大部分的集群部署采用的网络结构:
前端PC、手机的请求首先会打在LB(Load Balance 负载均衡,常见的有Nignx、HAProxy)服务器上,LB服务器再根据负载策略将这些请求转发至后面的服务,以达到请求分散的目的。正常来说,在集群环境下,同个用户的请求可能会被分发到不同的服务器上,假如登录操作时在SERVER1完成的,即SERVER1缓存了用户的登录状态,但SERVER2和SERVER3并不知情,如果该用户的后续操作被分配到了SERVER2或SERVER3上,这时就会要求该用户重新登录,这就是典型的会话状态集群不同步问题。
集群会话的解决方案
常见的三种方案:
- session保持
- session复制
- session共享
1、session保持也叫粘滞会话(Sticky Sessions),通常采用IP哈希负载策略将来自相同客户端的请求转发至相同的服务器上进行处理。session保持虽然避开了集群会话,但也存在一些缺陷。例如,某个营业部的网络使用同个IP出口,那么使用该营业部网络的所有员工实际的源IP其实是同一个,在IP哈希负载下,这些员工的请求都将被转发到相同的服务器,存在一定程度的负载失衡。
2、session复制是指在集群服务器之间同步session数据,以达到各个实例之间会话状态一致的做法。但毫无疑问,在集群服务器之间进行数据同步的做法非常不可取,尤其是在服务器实例很多的情况下,任何变动都需要其他所有实例同步,不仅消耗数据带宽,还回占用大量资源。
3、session共享则要实用得多。session共享指将session从服务器内存抽离出来,集中存储到独立的数据容器,并由各个服务器共享。
由于所有的服务器实例单点存取session,所以集群不同步的问题自然也就不存在了,而且独立的数据容器容量相较于服务器内存要大得多。另外,与服务本身分离、可持久化等特性使得会话状态不会因为服务停止而丢失。当然,session共享并非没有 缺点,独立的数据容增加了网络交互,数据容器的读/写性能、稳定性以及网络I/O速度都成为性能的瓶颈。基于这些问题,尽管在理论上使用任何存储介质都可以实现session共享,但在内网环境下,高可用部署的Redis服务器无疑为最优选择。Redis基于内存的特性让它拥有极高的读/写性能,高可用部署不仅降低了网络I/O损耗,还提高了稳定性。
整合Spring Session解决集群会话问题
Spring Security提供的会话并发控制是基于内存实现的,在集群部署时如果想要使用会话并发控制,则必须进行适配。
session共享,本质上就是存储容器的变动,但如何得到最优存取结构、如何准确清理过期会话,以及如何整合WebSocket等无法回避。Spring Session就是专门用于解决集群会话问题的,它不仅为集群会话提供了非常完善的支持,与Spring Security的整合也有专门的实现。
Spring Session支持多种类型的存储容器,包括Redis、MongoDB等。由于以下的整合都是基于Redis的。
添加依赖
1 | <!--spring session核心依赖--> |
配置类
1 | # 配置redis |
HttpSessionConfig
1 | package com.louris.springboot.config; |
WebSecurityConfig
1 | package com.louris.springboot.config; |
密码加密
潜在风险:
- “拖库”
- 反查表与彩虹表都是通过对散列值或散列值的加工从而逆向推导出密码的;
对应策略:
不可逆散列算法:
- 加盐加密:指在计算摘要值之前,为原文附上额外的随机值,已达到扰乱目的的加密方式。加盐等同于阻断了散列值与密码的直接对应关系,使得逆向推导的破解方式不再具有威慑力,但最经典的穷举法依然会带来威胁。正向穷举时,盐值的阻碍非常小,其计算耗时在当前来说完全可以接受,尤其在算力强的计算平台上。需要一种慢的加密手段,慢到暴力破解无法忍受,慢到超级计算机也无可奈何。
- BCrypt正是这样一种算法。
可逆加密算法:
- 非对称加面算法RSA
- 对称加密算法DES、AES
Spring Security的密码加密机制
原理
Spring Security内置了密码加密机制,只需使用一个PasswordEncoder
接口即可。
1 | // |
- 该接口定义了
encode
和matches
两个方法,当用户数据库存储用户密码时,加密过程用encode
方法,matches
方法用于判断用户登录时输入的密码是否正确; - 此外,Spring Security还内置了几种常用的
PasswordEncoder
接口,例如,StandardPasswordEncoder
中的常规摘要算法(SHA-256等)、BCryptPasswordEncoder
加密,以及类似BCrypt
的慢散列加密Pbkdf2PasswordEncoder
等,官方推荐使用BCryptPasswordEncoder
。
实现
自定义明文密码编码器
之前的自定义PasswordEncoder
是明文方式。
1 | package com.louris.springboot.bean; |
注入内置密码编码器
运行两次,可以发现同一个密码加密后的密文不一致,所以不能被破解,但是通过matches
可以发现密码一致!
1 |
|
自定义兼容密码编码器
通常情况下,在新系统中使用BCrypt加密不需要考量太多,但老系统由于存在大量旧数据,草率接入会导致老用户无法登录,需要自定义实现。
1 | package com.louris.springboot.bean; |
再进一步,如果我们不仅想要兼容,还想将不安全的旧密码无缝修改成BCrypt密文,如何操作?
(1)如果旧密码都是未经任何加密的明文,也许“跑库”修改是非常好的一种选择,但并非所有系统都有这么理想的状态;
(2)如果旧密码都是被散列加密过的,那么可以采用下面两种方法解决:
① 使用增量更新的方法。当用户输入的密码正确时,判断数据库中的密码是否为BCrypt密文,如果不是,则尝试使用用户输入的密码生成BCrypt密文并写回数据库;
② 以旧的加密方案作为基础接入BCrypt加密。例如,旧的方案是MD5加密,即数据库中的所有密码都是MD5形式的密码,那么直接把这些密码当作明文,先“跑库”生成BCrypt密文,再使用encode和matches两个方法在执行BCrypt加密之前都先用MD5运算一遍即可。
跨域与CORS
跨域是一种浏览器同源安全策略,即浏览器单方面限制脚本的跨域访问。
认识跨域
很多人误认为资源跨域时无法请求,实际上,通常情况下是可以正常发起的(注意,部分浏览器存在特例),后端也正常进行了处理,只是在返回时被浏览器拦截,导致响应内容不可使用。可以论证这一点的著名案例就是CSRF跨站攻击。
此外,我们平常所说的跨域实际上都是在讨论浏览器行为,包括各种WebView容器等(其中,以XmlHttpRequest的使用为主)。由于JavaScript运行在浏览器之上,所以Ajax的跨域成为“痛点”。
实际上,不仅不同站点间的访问存在跨域问题,同站点间的访问可能也会遇到跨域问题,只要请求的URL与所在页面URL首部不同即产生跨域,例如:
- 在
http://a.baidu.com
下访问https://a.baidu.com
资源形成协议跨域; - 在
a.baidu.com
下访问b.baidu.com
资源会形成主机跨域; - 在
a.baidu.com:80
下访问a.baidu.com:8080
资源会形成端口跨域;
URL首部是指:
1 | window.location.protocol + window.location.host + window.location.host.port |
从协议部分开始到端口部分结束,只要与请求URL不同即被认为跨域,域名与域名对应的IP也不能幸免。
浏览器解决跨域问题的方法有多重,包括JSONP、Ngnix转发和CORS等。其中,JSONP和CORS需要后端参与。
实现跨域之JSONP
JSONP(JSON With Padding)是一种非官方的解决方案。由于浏览器允许一些带src属性的标签跨域,例如,iframe、script、img等,所以JSONP利用script标签可以实现跨域。
例如,一个用于获取用户列表的API:
1 | curl -X GET http://blurooo.com/users |
正常情况下,会返回用户信息json。
但是在跨域的情况下,浏览器的同源策略导致用户无法读取响应信息,此时前端可以使用script标签去加载。
1 | <script src="http://blurooo.com/users"></script> |
这样便可以成功获取响应信息了,只是得到的JSON数据无法直接在JavaScript中使用。
如果后端接入,那么在返回浏览器之前应将响应信息包装成如下形式。
1 | jsonp({ |
对于Java Script而言,这就是一个普通的函数调用。
1 | jsonp(...params) |
但是jsonp这个函数并不存在,所以需要定义一个jsonp函数,以便从该函数内获取数据。
1 | var jsonp = function(data){ |
到这一步并不完善,因为它将导致后端无法正确处理非JSONP的请求,所以通常会约定一个参数callback,带上需要包装的函数名
1 | <script src="http://blurooo.com/users?callback=jsonp"></script> |
后端得到callback参数后,会使用该值包装JSON数据。需要注意的是,此时定义的jsonp函数必须在window对象下
1 | window.jsonp = function(data){ |
如果需要挂载到别的对象下,那么与后端约定即可,这取决于后端的包装形式。通常为了更方便地使用JSONP,前端也会做一些简单的封装。
1 | var getJsonp = function(url, success){ |
JSONP的原理很简单,几乎兼容所有浏览器,实现起来也并不困难,但只支持GET请求跨域,局限性较大。对于部分不需要考虑兼容老旧浏览器的系统来说,CORS的方案显得更为优雅、灵活。
实现跨域之CORS
- CORS(Cross-Origin Resource Sharing)的规范中有一组新增的HTTP首部字段,允许服务器声明其提供的资源允许哪些站点跨域使用。
- 通常情况下,跨域请求即便在不被支持的情况下,服务器也会接收并进行处理,在CORS的规范中则避免了这个问题。
- 浏览器首先会发起一个请求方法为OPTIONS的预检请求,用于确认服务器是否允许跨域,只有在得到许可后才会发出实际请求。
- 此外,预检请求还允许u服务器同志浏览器跨域携带身份凭证(如cookie)。
CORS新增的HTTP首部字段
CORS新增的HTTP首部字段由服务器控制,常用首部字段如下:
(1)Access-Control-Allow-Origin
:
- 允许取值为
<origin>
或*
。 <origin>
指被允许的站点,使用URL首部匹配原则。*
匹配所有站点,表示允许来自所有域的请求。但并非所有情况都简单设置即可,如果需要浏览器在发起请求携带凭证信息,则不允许设置为*
。- 如果设置了具体的站点信息,则响应头中的
Vary
字段还需要携带Origin
属性,因为服务器对不同的域会返回不同的内容:(2)1
2Access-Control-Allow-Origin: http://bluroo.com
Vary: Accept-Encoding, OriginAccess-Control-Allow-Headers
字段仅在预检请求的响应中指定有效,用于表明服务器允许跨域的HTTP方法,多个方法之间用逗号隔开。
(3)Access-Control-Allow-Headers
字段仅在预检请求的响应中指定有效,用于表明服务器允许携带的首部字段。多个首部字段之间用逗号隔开。
(4)Access-Control-Max-Age
字段用于指明本次预检请求的有效期,单位为秒。在有效期内,预检请求不需要再次发起。
(5)Access-Control-Allow-Credentials
字段取值为true
时,浏览器会在接下来的真实请求中携带用户凭证信息(cookie等),服务器也可以使用Set-Cookie
向用户浏览器写入新的cookie。注意,使用Access-Control-Allow-Credentials
时,Access-Control-Allow-Origin
不应该设置为*
。
总体来说,CORS是一种更安全的官方跨域解决方案,它依赖于浏览器和后端,即当需要用CORS来解决跨域问题时,只需要后端做出支持即可。前端在使用这些域时,基本等同于访问同源站点资源。注意,CORS不支持IE8以下版本的浏览器。
访问控制场景
(1)简单请求
在CORS中,并非所有跨域访问都会触发预检请求。例如,不懈怠自定义请求头信息的GET请求、HEAD请求,以及Content-Type为application/x-www-form-urlencoded
、multipart/form-data
或text/plain
的POST请求,这类请求被称为简单请求。
浏览器在发起请求时,会在请求头中自动添加一个Origin属性,值为当前页面的URL首部。当服务器返回响应时,弱存在跨域访问控制属性,则浏览器会通过这些属性判断本次请求是否被允许,如果允许,则跨域成功(正常接收数据)。
1 | HTTP/1.1 200 OK |
这种跨域请求非常简单,只需后端在返回的响应头中添加Access-Control-Allow-Origin
字段并填入允许跨域访问的站点即可。
(2)预检请求
预检请求不同于简单请求,它会发送一个OPTIONS请求到目标站点,以查明该请求是否安全,防止请求对目标站点的数据造成破坏。
- 若是请求以GET、HEAD、POST以外的方法发起;
- 或者使用POST方法,但请求数据为
application/x-www-form-urlencoded
、multipart/form-data
和text/plain
以外的数据类型; - 再或者,使用了自定义请求头,则都会被当成预检请求类型处理。
(3)带凭证的请求
1 | var request = new XMLHttpRequest(); |
上面在使用XMLHttpRequest时,指定了withCredentials为true。浏览器在实际发出请求时,将同时向服务器发送cookie,并期待在服务器返回的响应信息中指明Access-Control-Allow-Credentials为true,否则浏览器会拦截,并抛出错误。
启用Spring Security的CORS支持
实现
1 |
|
原理
1 | // |
跨域请求伪造的防护
CSRF(Cross Site Request Forgery),可译为跨域请求伪造,是一种利用用户带登录态的cookie进行安全操作的攻击方式。CSRF实际上并不难防,但常常被系统开发者忽略,从而埋下巨大的安全隐患。
CSRF的攻击过程
假如有一个博客网站,为了激励用户写出高质量的博文,设定了一个文章被点赞就能奖励现金的机制,于是有了一个可用于点赞的API,只需要传入文章id即可:
1 | http://blog.xxx.com/articles/like?id=xxx |
在安全策略上,限定必须是本站有效登录用户才可以点赞,且每个用户对每篇文章仅可点赞一次,防止无限刷赞的情况发生。
这套机制推行起来似乎没有什么问题,直到我们发现有个用户的文章总是有非常多的点赞数,哪怕只是发表了一条个人状态也有非常多的点赞数,而这些点赞记录也确实都是本站的真实用户发起的。觉察到异常之后,开始对这个用户的所有行为进行排查,发现该用户几乎每篇文章都带有一张很特别的图片,这些图片的URL无一例外地指向了对应文章的点赞API。由于图片是由浏览器自动加载的,所以每个查看过该文章的人都会不知不觉为其点赞。很显然,噶用户利用了系统的CSRF漏斗实施刷赞,这是网站开发人员始料未及的。
有人可能认为这仅仅是因为 点赞API设计不理想导致的,应当使用POST请求,这样就能避免上面的场景。然而,当使用POST请求时,确实避免了如img、script、iframe等标签自动发起GET请求的问题,但这并不能杜绝CSRF攻击的发生。一些恶意网站会通过表单的形式构造攻击请求:
1 | <form action="http://xxx.bank.com/xxx/transfer" method="post"> |
假如登录过某银行站点而没有注销,期间被诱导访问了带有类似攻击的页面,那么在该页面一旦单机按钮,很可能会导致在该银行的账户资金被直接转走。甚至根本不需要单击按钮,而是直接用JavaScript代码自动化该过程。
CSRF利用了系统对登录期用户的信任,使得用户执行了某些并非意愿的操作从而造成损失。如何真正地防范CSRF攻击,对每个有安全需求的系统而言都尤为重要。
CSRF的防御手段
一些工具可以检测系统是否存在CSRF漏洞,例如,CSRFTester。
在任何情况下,都应当尽可能地避免以GET方式提供涉及数据修改的API。在此基础上,防御CSRF攻击的方式主要有以下两种。
- HTTP Referer
HTTP Referer是由浏览器添加的一个请求头字段,用于标识请求来源,通常用在一些统计相关的场景,浏览器端无法轻易篡改该值。
回到前面构造POST请求实行CSRF攻击的场景,其必要条件就是诱使用户跳转拿到第三方页面,在第三方页面构造发起的POST请求中,HTTP Referer字段不是银行的URL(少部分老版本的IE浏览器可以调用API进行伪造,但最后的执行逻辑是放在用户浏览器上的,只要用户的浏览器版本较新,便可以避免这个问题),当校验到请求来自其他站点时,可以认为是CSRF攻击,从而拒绝该服务。
当然,这种方式简单便捷,但并非完全可靠。除前面提到的部分浏览器可以篡改HTTP Referer外,如果用户在浏览器中设置了不被跟踪,那么HTTP Referer字段就不会自动添加,当合法用户访问时,系统会认为是CSRF攻击,从而拒绝访问。
- CsrfToken认证
CSRF是利用用户的登录态进行攻击的,而用户的登录态记录在cookie中。其实攻击者并不知道用户的cookie存放了哪些数据,于是想方设法让用户自身发起请求,这样浏览器便会自行将cookie传送到服务器完成身份校验。
CsrfToken的防范思路是,添加一些并不存放于cookie的验证值,并在每个请求中都进行校验,便可以阻止CSRF攻击。
具体做法是在用户登录时,由系统发放一个CsrfToken值,用户携带该CsrfToken值与用户名、密码等参数完成登录。系统记录该会话的CsrfToken值,之后在用户的任何请求中,都必须带上该CsrfToken值,并由系统进行校验。
这种方法需要与前端配合,包括存储CsrfToken值,以及在任何请求中(包括表单和Ajax)携带CsrfToken值。安全性相较于HTTP Referer提高很多,但也存在一定的弊端。例如,在现有的系统中进行改造时,前端的工作量会非常大,几乎要对所有请求进行处理。如果都是XMLHttpRequest,则可以统一添加CsrfToken值;但如果存在大量的表单和a标签,就会变得非常繁琐。因此建议在系统开发之初考虑如何防御CSRF攻击。
使用Spring Security防御CSRF攻击
CSRF攻击完全是基于浏览器进行的,如果我们的系统前端并非在浏览器中运作,就应当关闭CSRF。
Spring Security通过注册一个CsrfFilter来专门处理CSRF攻击。
原理
CsrfToken
CsrfToken是一个用于描述Token值,以及验证时应当获取哪个请求参数或请求头字段的接口。
1 | // |
CsrfTokenRepository
CsrfTokenRepository则定义了如何生成、保存以及加载CsrfToken。
1 | // |
HttpSessionCsrfTokenRepository
在默认情况下,Spring Security加载的是一个HttpSessionCsrfTokenRepository。
1 | // |
- HttpSessionCsrfTokenRepository将CsrfToken值存储在HttpSession中,并指定前端把CsrfToken值放在名为”_csrf”的请求参数或名为”X-CSRF-TOKEN”的请求头字段里(可以调用相应的设置方法重新设定)。校验时,通过对比HttpSession内存储的CsrfToken值与前端携带的CsrfToken值是否一致,便能断定本次请求是否为CSRF攻击。
当使用HttpSessionCsrfTokenRepository时,前端必须用服务器渲染的方式注入CsrfToken值,例如jsp标签。
1 | <c:url value="/login" var="loginUrl"/> |
这种方式在某些单页应用中局限性较大,灵活性不足。
CookieCsrfTokenRepository
Spring Security还提供了另一种方式,即CookieCsrfTokenRepository。
1 | // |
CookieCsrfTokenRepository是一种更加灵活可行的方案,它将CsrfToken值存储在用户的cookie内。首先,减少了服务器HttpSession存储的内存消耗;其次,当用cookie存储CsrfToken值时,前端可以用JavaScript读取(需要设置该cookie的httpOnly属性为false),而不需要服务器注入参数,在使用方式上更加灵活。
存储在cookie上,不就又可以被CSRF利用了吗?事实上并不可以。cookie只有在同域的情况下才能被读取,所以杜绝了第三方站点跨域获取CsrfToken值的可能。CSRF攻击本身是不知道cookie内容的,只是利用了当请求自动携带cookie时可以通过身份验证的漏洞。但服务器对CsrfToken值的校验并非取自cookie,而是需要前端手动将CsrfToken值作为参数携带在请求里,所以cookie内的CsrfToken值并没有被校验的作用,仅仅作为一个存储容器使用。
CsrfFilter
1 | // |
LazyCsrfTokenRepository
2016年,Spring Security社区有人指出,csrfFilter总是在创建会话时,触发生成并保存一个CsrfToken值,即便该会话实际上用不到这个CsrfToken值。例如,当只是使用一些公开的GET类型API时,既不需要身份验证,也不需要CSRF攻击验证,那么此时保存的CsrfToken值就是浪费空间。
于是Spring Security新增了LazyCsrfTokenRepository,用来延时保存CsrfToken值(允许创建,但只有真正使用时才会被保存)。
1 | // |
- 可以看到,LazyCsrfTokenRepository并非独立使用一个csrfTokenRepository,而是专门用于包裹其他csrfTokenRepository。LazyCsrfTokenRepository先是覆盖了原csrfTokenRepository的saveToken方法,使得csrfFilter中的saveToken方法失去实际的保存效果;
- 接着又修改了generateToken,使得CsrfToken在首次调用getToken时,才真正调用saveToken方法对CsrfToken进行保存。
- 此特性发布在Spring Security 4.1.0.RELEASE版本中,在该版本之后,我们看到的csrfConfigurer已经默认使用LazyCsrfTokenRepository来包裹HttpSessionCsrfTokenRepository。
1 | public final class CsrfConfigurer<H extends HttpSecurityBuilder<H>> extends AbstractHttpConfigurer<CsrfConfigurer<H>, H> { |
实现
1 |
|
单点登录与CAS
单点登录(Single Sign On, SSO)是指在多个应用系统中,只需登录一次,即可同时以登录态共享企业所有相关又彼此独立的系统的功能。对于旗下拥有众多系统的企业来说,单点登录不仅降低了用户的登录成本,统一了不同系统间的账号体系,还减少了各个系统在用户设计上付出的精力。
单点登录
单个应用的登录方案
登录请求会话流程
- 服务器在接收请求后,会为每个新用户生成一个会话ID,该会话ID不仅绑定了用户信息,还会被设置到用户的浏览器中。由于浏览器每次发起请求时都会自动携带cookie,所以服务器可以通过cookie获取会话ID,从而找到请求对应的用户。
设置cookie是在HTTP中进行的,只需在响应体重添加首部信息:Set-Cookie,浏览器便会自动解析并存储cookie:
1 | Set-Cookie: <cookie-name>=<cookie-value>; Domain=<domain-value>; |
Web框架一般也会提供cookie的设置方法,并且不需要开发者了解其交互细节。例如,使用Servlet设置cookie。
1 | Cookie cookie = new Cookie("spring.security", "blurooo"); |
互联网Domian与Cookie
在互联网中,几乎每个域名都有自己的cookie,如果每一个请求都携带浏览器存储的所有cookie,那么使用的互联网服务越多,积累的cookie就越多。而cookie越多,则每次请求所要传输的流量就越大,获得的服务响应时间就越长,最终浏览器和互联网都将不堪重负。因此cookie的Domain设置至关重要。
Domain实际上圈定了cookie的作用范围。例如,访问oauth.chenmuxin.cn时会获得一个cookie,该cookie的Domain为oauth.chenmuxin.cn。只有访问oauth.chenmuxin.cn或xx.oauth.chenmuxin.cn时,浏览器才会将该cookie传输到服务器。如果访问的是chenmuxin.cn或sso.chenmuxin.cn,那么该cookie不会生效。
相反,在设置cookie时,允许将Domain指定为当前域名或当前域名的所有上级域名。例如,oauth.chenmuxin.cn仅允许将cookie的Domain设置为oauth.chenmuxin.cn或其上级域名chenmuxin.cn,其他Domain将被浏览器拒绝。
如果用户在mail.google.com汇总完成了登录,暗恶魔表示会话ID的cookie的Domain一般会被设置为mail.google.com。也就是说,会话ID仅在访问mail.google.com或其子域名时才会生效,无法共享给google.com或maps.google.com。
如果将Domain设置为google.com,那么是不是所有google.com下的应用都可以共享了?的确如此。但还有些问题需要考虑。假如我们把会话ID设置到顶级域名下,使该顶级域名的所有子域名都可以共享,但却并非所有系统都能识别。因为会话ID是由该域名下的某个服务生成的,会话的数据通常只存储在该服务汇总,为了解决这个问题,还需要引入共享会话的方案。
将cookie的域设置为顶级域名解决了cookie共享问题,但这对不同域名下的系统是行不通的。出于安全考虑,cookie无法在服务器实现跨域设置,即,在taobao.com下无法直接将cookie设置到tmall.com。即便可以设置,也会存在一个问题:每次修改cookie时都需要将cookie再次 同步到所有其他系统中,一旦有新增或删减,俺么每个系统都必须修改。
阿里旗下的系统是如何解决这个问题的?实际上,这些系统有一个统一的登录服务:login.taobao.com。
其他略
认识CAS
如果企业旗下的所有系统都使用同样的顶级域名,那么实现单点登录就会变得非常简单,只需将cookie的域设置为顶级域名,在服务器使用共享会话的方案即可。但很多时候并没有这么理想的状态,因此实现单点登录的成本相对较高。
实际上,开源社区提供了一套非常好的系统:CAS(Central Authentication Service,中央验证服务),利用CAS实现单点登录将大大降低开发及维护成本。
CAS由CAS Server和CAS Client两部分组成:
- CAS Server是一个单点的验证服务;
- CAS Client是共享CAS Server登录态的客户端。
CAS的三个重要术语:
- Ticket Grant Ticker(TGT): TGT是用户登录后生成的票根,包含用户的认证身份,用户与CAS Server中,类似于我们常见的服务器会话;
- Ticket Granted Cookie(TGC):TGC是存储在cookie中一段数据,类似于会话ID,用户与CAS Server进行交互时,帮助用户找到对应的TGT;
- Service Ticket(ST):ST是CAS Server使用TGT签发的一张一次性票据,CAS Client使用ST与CAS Server进行交互,以获取用户的验证状态。
搭建CAS Server
为了验证CAS单点登录,需要在本地开发环境中搭建一套用于测试的CAS Server
导入CAS Server项目
CAS Server的搭建基于GitHub项目https://github.com/apereo/cas-overlay-template
。导入IDEA,待下载完所有依赖的maven插件以及最关键的cas-overlays后,在项目目录结构中会出现overlays目录。
只做本地密匙库
CAS Server默认使用HTTPS进行访问,并要求我们提供一个密匙库。
可以使用Java自带的密匙和整数管理工具keytool只做本地密匙库。
以管理员方式打开cmd。
(1)只做本地密匙库keytool -genkey -alias casserver -keyalg RSA -keystore D:\keystore
出现“您的名字与姓氏是什么?”一项,应当填写CAS Server的域名,否则在后续的单点登录过程中会遇到问题。
如果只是用于本地开发测试,则域名可以随便填写,并通过配置hosts的方式使其生效。其他参数随便填或直接跳过。
(2)使用export子命令导出证书keytool -export -trustcacerts -alias casserver -file D:\cas.cer -keystore D:\keystore
执行效果是:从D:\keysotre
这个密匙库中,导出别名为casserver的证书到D:\cas.cer
文件中。密匙库口令为keystore生成时自定义的口令。
(3)使用import子命令导入证书keytool -import -trustcacerts -alias casserver -file D:\cas.cer -keystore "C:\Program Files\Java\jdk-14.0.2\lib\security\cacerts"
执行效果是:以casserver作为别名,把D:\cas.cer
这个证书文件导入C:\Program Files\Java\jdk-14.0.2\lib\security\cacerts
证书库中。密匙库口令为cacerts的默认口令:changeit
。
(4)覆盖CAS Server原配置
新建目录src/main/resources
,将overlays/{cas-server}/WEB-INF/classes/application.properties
复制到此目录下,后续将用这个新的配置文件覆盖CAS Server的配置。
在CAS Server配置中,关于SSL证书的三个主要配置如下。
1 | server.ssl.key-store=file:/etc/cas/thekeystore |
key-store
指定密匙库的位置;key-store-password
指定密匙库的口令;key-password
指定密匙的口令;
可以将其修改为:server.ssl.key-store=file:D:\\keystore
或可以把keystore复制到resources目录下,把key-store修改为下面的形式server.ssl.key-store=classpath:keystore
(5)启动CAS Servermvn spring-boot:run
其他略
用Spring Security实现CAS Client
在实现CAS Client过程中,先梳理当前准备好的两个域名信息:
- 客户端域名为client.cas.chenmuxin.cn, 配置hosts,指向127.0.0.1;
- 服务器域名为cas.chenmuxin.cn,配置hosts,指向127.0.0.1;
引入依赖包
1 | <dependency> |
CAS Client的相关配置信息
1 | ## CAS Client的相关配置信息 |
CAS Client的配置类
1 | package com.louris.springboot.config; |
WebSecurity配置类
1 | true) //里面已经加入了@Configuration (debug = |
HTTP认证
HTTP基本认证
HTTP基本认证时在RFC2016中定义的一种认证模式,优点是使用简单、没有复杂页面交互。
HTTP基本认证有4个步骤:
(1)客户端发起一条没有携带认证信息的请求;
(2)服务器返回一条401 Unauthorized响应,并在WWW-Authentication首部说明认证形式,当进行HTTP基本认证时,WWW-Authentication会被设置为Basic realm=”被保护页面”;
(3)客户端收到401 Unauthorized响应后,弹出对话框,询问用户名和密码。当用户完成后,客户端将用户名和密码使用冒号拼接并编码为Base64形式,然后放入请求的Authorization首部发送给服务器;
(4)服务器解码得到客户端发来的用户名和密码,并在验证它们是正确的之后,返回客户端请求的报文;
如果不使用浏览器访问HTTP基本认证保护的页面,则自行在请求头中设置Authorization也是可以的。
总体而言,HTTP基本认证时一种无状态的认证方式,与表单认证相比,HTTP基本认证时一种基于HTTP层面的认证方式,无法携带session,即无法实现Remember-me功能。另外,用户名和密码在传递时仅做一次简单的Base64编码,几乎等同于明文传输,极易出现密码被窃听和重放攻击等安全性问题,在实际系统开发中很少使用这种方式来进行安全验证。如果有必要也应使用加密的传输层(例如HTTPS)来保障安全。
HTTP摘要认证
与HTTP基本认证类似,HTTP摘要认证也是基于简单的“挑战——回应”范例,即在未经验证的请求发起时,服务器会首先返回一个401应答(挑战),并携带验证相关的参数,期待客户端依据这些参数继续做出回应,以完成整个验证过程。
HTTP摘要认证的回应与HTTP基本认证相比要复杂得多,其涉及的参数如下:
- username:用户名
- password:用户密码
- realm:认证域,由服务器返回
- opaque:透传字符串,客户端应原样返回
- method:请求的方法
- nonce:由服务器生成的随机字符串
- nc:即nonce-count,指请求的次数,用于计数,防止重放攻击。qop被指定时,nc也必须被指定
- cnonce:客户端发给服务器的随机字符串,qop被指定时,cnonce也必须被指定
- qop:保护级别,客户端根据此参数指定摘要算法。若取值为auth,则只进行身份验证;若取值为auth-int,则还需要校验内容完整性
- uri:请求的uri
- response:客户端根据算法算出的摘要值
- algorithm:摘要算法,目前仅支持MD5
- entity-body:页面实体,非消息实体,仅在auth-int中支持
Spring Security对HTTP摘要认证的集成支持
对于服务器而言,最重要的字段是nonce;对于客户端而言,最重要的字段是response
nonce是由服务器生成的随机字符串,包含过期时间和密钥。在Spring Security中,其生成算法如下:base64(expirationTime + ":" + md5(expirationTime + ":" + key))
其中,expirationTime默认为300s,在DigestAuthenticationEntryPoint中可以找到Spring Security发送“挑战”数据的过程。
1 | // |
- Spring Security默认实现了qop为auth的摘要认证模式。如果在客户端最后发起的“回应”中,摘要有效但已过期,那么Spring Security会重新发回一个“挑战”,并增加stale=true字段告诉客户端不需要重新弹出验证框,用户名和密码是正确的,只需使用新的nonce尝试即可。
response是客户端最关注的字段,它是整个验证能否通过的关键,它的算法取决于qop,如果qop未指定,俺么它的算法如下。
1 | A1 = md5(username:realm:password) |
如果qop指定为auth,则算法如下。
1 | A1 = md5(username:realm:password) |
这在Spring Security的实现代码中有体现
1 | // |
验证的大体流程:客户端首先按照约定的算法计算并发送response,服务器接收之后,以同样的方式计算得到一个response。如果两个response相同,则证明该摘要正确。接着用base64解码原nonce得到过期时间,以验证该摘要是否还有效。
需要注意的是,由于HTTP摘要认证必须读取用户的明文密码,所以不应该在Spring Security中使用任何密码加密方式。
编码实现
1 | package com.louris.springboot.config; |
HTTP摘要认证与HTTP基本认证一样,都是基于HTTP层面的认证方式,不使用session,因而不支持Remember-me。虽然解决了HTTP基本认证密码明文传输的问题,但并未解决密码明文存储的问题,依然存在安全隐患。HTTP摘要认证与HTTP基本认证相比,仅仅在非加密的传输层中有安全优势,但是其相对复杂的实现过程,使得它并不能成为一种被广泛使用的认证方式。
@EnableWebSecurity与过滤器链机制
为什么加上@EnableWebSecurity注解就可以让Spring Security起作用?Spring Security又是通过什么方式来拦截请求并执行认证的?
@EnableWebSecurity
- @EnableWebSecurity是开启Spring Security的默认行为,它通过@Import注解导入了WebSecurityConfiguration类。
- 即当我们使用@EnableWebSecurity注解时,等同于将WebSecurityConfiguration类放入Spring的IoC容器里。
- 也就是说,WebSecurityConfiguration借由@EnableWebSecurity注解得到初始化机会。
- @EnableWebSecurity还有一个debug参数用于指定是否采用调试模式,默认为false。在调试模式下,每个请求的详细信息和所经过的过滤器,甚至其调用栈都会被打印到控制台。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package org.springframework.security.config.annotation.web.configuration;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.security.config.annotation.authentication.configuration.EnableGlobalAuthentication;
(RetentionPolicy.RUNTIME)
({ElementType.TYPE})
.class, SpringWebMvcImportSelector.class, OAuth2ImportSelector.class, HttpSecurityConfiguration.class}) ({WebSecurityConfiguration
@EnableGlobalAuthentication
@Configuration
public @interface EnableWebSecurity {
boolean debug() default false;
}
WebSecurityConfiguration
略
用Spring Social实现OAuth对接
OAuth简介
开放授权(Open Authorization, OAuth)是一种资源提供商用于授权第三方应用代表 资源所有者获取有限访问权限的授权机制。
OAuth的运行流程
4个重要角色:
(1)Resource Owner:资源所有者,通常指用户,例如每一个QQ用户;
(2)Resource Server:资源服务器,指存放用户受保护资源的服务器,通常需要通过Access Token(访问令牌)才能进行访问。例如,存储QQ用户基本信息的服务器,充当的便是资源服务器的角色;
(3) Client:客户端,指需要获取用户资源的第三方应用,如CSDN网站;
(4)Authorization Server:授权服务器,用于验证资源所有者,并在验证成功之后向客户端发放相关访问令牌。例如,QQ授权登录页面。
流程:
(A)客户端要求用户提供授权许可;
(B)用户同意向客户端提供授权许可;
(C)客户端携带用户提供的授权许可向授权服务器申请资源服务器的访问令牌;
(D)授权服务器验证客户端及其携带的授权许可,确认有效后发放访问令牌;
(E)客户端使用访问令牌向资源服务器申请资源;
(F)资源服务器验证访问令牌,确认无误后向客户端提供资源;
其中,B步骤最为关键,OAuth定义了4种授权模式,用于将用户的授权许可提供给客户端。
1.授权码模式(Authorization Code)
授权码模式是功能最完整、流程最严密的授权模式,它将用户引导到授权服务器进行身份验证,授权服务器将发放的访问令牌传递给客户端。
例如,QQ登录方式,就是由CSDN网站引导到QQ授权服务器进行身份验证的。
一个典型的QQ登录页面URL如下:
1 | https://graph.qq.com/oauth2.0/show?which=Login&display=pc&response_type=code&client_id=100270989&redirect_uri=https://passport.csdn.net/account/login?oauth_provider=QQProvider&state=test |
response_type指授权类型,为必要项,固定为code。client_id指客户端id,为必要项;
state指客户端的状态,通常在授权服务器重定向时原样返回;
scope为申请的权限范围,如获取用户信息、获取用户相册等,由授权服务器抽象为具体的条目;
redirect_uri为授权通过后的重定向URL,授权服务器将在用户登录完成之后重定向到类似下面的地址
https://passport.csdn.net/account/login?oauth_provider=QQProvider&code=xxx&state=test
code为申请访问令牌必备的授权码(有效期较短,注意与访问令牌的区别)。客户端拿到code之后需要向授权服务器申请访问令牌(仅可使用一次,用完作废);
申请令牌时也有一些关键参数,其中,grant_type指授权类型,在授权码模式中,该值需要设置为authorization_code;client_id指客户端id;code指前面获取的授权码;redirect_uri指重定向URL。通过构建一个HTTP请求发起访问令牌的申请,如果成功,则会 得到访问令牌,以及一些令牌刷新时需要的参数;
https://graph.qq.com/oauth2.0/xxx?grant_type=authorization_code&code=xxx&...
2.隐式授权模式(Implicit)
隐式授权模式的客户端一般是指用户浏览器。访问令牌通过重定向的方式传递到用户浏览器中,再通过浏览器的JavaScript代码来获取访问令牌。由于访问令牌直接暴露在浏览器端,所以隐式授权模式可能会导致访问令牌被黑客获取,仅适用于需要临时访问的场景。
3.密码授权模式(Password Credentials)
客户端直接携带用户的密码向授权服务器申请令牌。这种登录操作不再像前两种授权模式一样跳转到授权服务器进行而是由客户端提供专用页面。如果用户信任该客户端(通常为信誉度高的著名公司),用户便可以直接提供密码, 客户端在不存储用户密码的前提下完成令牌的申请。
4.客户端授权模式(Client Credentials)
客户端授权模式实际上并不属于OAuth的范畴,因为它的关注点不再是用户的私有信息或数据,而是一些由资源服务器持有但并非完全公开的数据,如微信的公众平台授权等。
客户端授权模式通常由客户端提前向授权服务器申请应用公钥、密钥,并通过这些关键信息向授权服务器申请访问令牌,从而得到资源服务器提供的资源。