通过代码,实例讲解如何使用 spring security 保护应用
基本概念
antMatcher 和 antMatchers
antMatcher() 是HttpSecurity 的一种方法,它与 authorizeRequests() 没有任何关系。基本上,http.antMatcher() 告诉Spring 只有在路径匹配此模式时才配置 HttpSecurity。
然后使用 authorizeRequests().antMatchers() 将授权应用于您在 antMatchers() 中指定的一个或多个路径。如permitAll() 或 hasRole(‘USER3’)。这些仅在第一个 http.antMatcher() 匹配时才会应用。
创建一个类并继承WebSecurityConfigurerAdapter这个方法,并在之类中重写configure的3个方法,其中3个方法中参数包括为
1、 HttpSecurity(HTTP请求安全处理),
2、 AuthenticationManagerBuilder(身份验证管理生成器)和
3、 WebSecurity(WEB安全)。
session的缺点:
- 数据以纯文本形式存储在服务器上:即使数据通常不存储在公用文件夹中,但是任何具有访问权限的人都可以读取到session会话文件的内容。
- 文件读写请求:每次会话开始和数据被修改时,服务器都需要更新会话文件,这些文件读写都要消耗资源。每当应用程序发送会话cookie时也是如此。如果你的应用用户量比较大,将会导致服务器响应速度变慢,当然你也可以使用redis基于内存来存储session或者加大硬件配置,但随着认证用户的增多,服务端的开销会明显增大,这都不是最终解决办法。
- 分布式应用:由于session文件默认存储在文件系统中,因此对于多台服务器分布式负载均衡、集群方式架构的高可用应用就显得有点力不从心了,你可能要考虑session会话同步的问题了。
什么是 JWT
JWT:JSON web Token,简称JWT,本质是一个token,是一种紧凑的URL安全方法,用于在网络通信的双方之间传递。一般放在HTTP的headers 参数里面的authorization里面(这个是可以自己定义放在哪里的,毕竟只是一种验证用户的方式),值的前面加Bearer关键字和空格。除此之外,也可以在url和request body中传递。
JWT包含三个部分,分别是头部、载荷与签名。其中头部包含的是加密的一些信息,签名是根据前面两部分生成的。最主要的就是咱们存放信息的载荷部分了。
jjwt的使用,也就是java版本的jwt包的使用
Json Web Token (简称JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519)。该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,该token也可直接被用于认证,也可被加密。
JWT 的几个特点
(1)JWT 默认是不加密,但也是可以加密的。生成原始 Token 以后,可以用密钥再加密一次。
(2)JWT 不加密的情况下,不能将秘密数据写入 JWT。
(3)JWT 不仅可以用于认证,也可以用于交换信息。有效使用 JWT,可以降低服务器查询数据库的次数。
(4)JWT 的最大缺点是,由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。
(5)JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT 的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证。
(6)为了减少盗用,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输。
JWT比单个API密钥的优点:
- API密钥只是随机字符串,而JWT则包含可在一个时间范围或域内描述用户身份,授权数据和令牌有效性的信息和元数据。
- Oauth2兼容。
- JWT数据可以被检查。
- JWT有失效控制。
验证流程
- 用户使用用户名密码来请求服务器
- 服务器进行验证用户的信息
- 服务器通过验证发送给用户一个token
- 客户端存储token,并在每次请求时附送上这个token值
- 服务端验证token值,并返回数据
这个token必须要在每次请求时传递给服务端,它应该保存在请求头里, 另外,服务端要支持CORS(跨来源资源共享)策略,一般我们在服务端这么做就可以了Access-Control-Allow-Origin: *。
base64 和 base64URL
Base64 有三个字符+、/和=,在 URL 里面有特殊含义,所以要被替换掉:=被省略、+替换成-,/替换成_ 。这就是 Base64URL 算法。
比较理想的基于用户名和密码的登录流程
- 浏览器端获取用户输入的密码
- 使用 MD5 一类的哈希算法,生成固定长度的新密码,如 md5(username + md5(md5(password)))
- 再将密码哈希值和用户名提交给后端
- 后端根据用户名获取对应的盐,使用用户名和密码哈希值,算出一个密文
- 根据用户名和密文去数据库查询
- 如果查询成功,则生成密钥,返回给浏览器
在哈希算法中,首选是 SHA2 系列,虽然安全由于 SHA1 的原因而被质疑,但至少目前还没有证明有什么纰漏。MD5 由于用得太多,而且彩虹表实在过于泛滥,并不推荐使用。另外一个问题,哈希一遍是不是就够了呢?当然不,不仅要多次哈希,而且还要与用户名一类的数据混加,比如,可以使用下面的方式来在客户端加密原始密码:
1
2
3 sha256(
sha265(sha265(password)) + sha265(username)
)后端获取到客户端传来的密码之后,再通过加盐哈希进行再加密。比如像下面这样:
1
2
3 sha256(
sha256(username + sha256(password + salt)) + salt + sha256(username + salt)
)注意,盐的保存非常关键,务必将它与用户信息分开存放。
密文和盐的更新与不可追溯
现在密码已经分别在客户端和后端多次哈希,还加了盐,好像已经很安全了。但其实,我们还可以更安全。那就是经常变更盐,让用户信息表中的密文字段值也经常变化。这样,除非同时拿到用户信息和盐,否则依然无效。
那什么时候变更盐和密文呢?由于后端是不存储客户端哈希的密文的,所以只有在登陆的时候,才能够进行盐和密文的修改。
打造一个安全的用户名密码登陆系统
浏览器主要完成以下工作:
- 获取用户输入的用户名及密码
- 通过输入的用户名和密码,进行哈希,得到浏览器端密文
- 将用户名和密文提交给后端
后端的验证流程如下:
- 获取前端提交的用户名及浏览器端密文
- 根据用户名,在数据库中查询出对应的盐 id
- 通过盐 id 取出对应的盐,再通过用户名、浏览器端密文和盐算出后端密文
- 根据用户名和后端密文到用户表查询,如果有结果,则表明登陆信息正确,返回给浏览器登陆成功的响应
- 生成新的盐,算出新的后端密文,并将两者更新到数据库中
spring security
对于表单登录的由UsernamePasswordAuthenticationFilter,
对于基本登录的(也就是http.httpBasic)则由BasicAuthenticationFilter来处理。
BCryptPasswordEncoder
每次运行都会得到不同的加密密码,但是这些加密后的密码都和 123456 相等。
spring security中的BCryptPasswordEncoder方法采用SHA-256 +随机盐+密钥对密码进行加密。SHA系列是Hash算法,不是加密算法,使用加密算法意味着可以解密(这个与编码/解码一样),但是采用Hash处理,其过程是不可逆的。
(1)加密(encode):注册用户时,使用SHA-256+随机盐+密钥把用户输入的密码进行hash处理,得到密码的hash值,然后将其存入数据库中。
(2)密码匹配(matches):用户登录时,密码匹配阶段并没有进行密码解密(因为密码经过Hash处理,是不可逆的),而是使用相同的算法把用户输入的密码进行hash处理,得到密码的hash值,然后将其与从数据库中查询到的密码hash值进行比较。如果两者相同,说明用户输入的密码正确。
这正是为什么处理密码时要用hash算法,而不用加密算法。因为这样处理即使数据库泄漏,黑客也很难破解密码(破解密码只能用彩虹表)。
实现步骤
定义实现UserDetails的SysUser类,实现接口的5个方法
SysUser implements Serializable, UserDetails{``` 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
定义实现UserDetailsService接口的SysUserDetailsService类。覆写loadUserByUsername方法,实现从数据库表中查询用户。
2. 集成WebSecurityConfigurerAdapter,定义Bean bCryptPasswordEncoder(),自动装载:sysUserDetailsService 、 bCryptPasswordEncoder
3. 覆写configure(AuthenticationManagerBuilder auth) 方法,代码如下:
```groovy
@Autowired
private SysUserDetailsService sysUserDetailsService
@Autowired
private BCryptPasswordEncoder bCryptPasswordEncoder
@Bean
BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder()
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception{
auth.userDetailsService(sysUserDetailsService).passwordEncoder(bCryptPasswordEncoder)
}如果使用内存权限验证,需要在加密的密码前加{xxxx}以表明加密方法,否则会报错。
finalPassword 1
2
3
4
5
6
## 引入 JWT
1. 定义JWTLoginFilter,继承UsernamePasswordAuthenticationFilter
```class JWTLoginFilter extends UsernamePasswordAuthenticationFilter覆写 attemptAuthentication 方法
覆写 successfulAuthentication 方法,生成token,并写入header中
定义JWTAuthenticationFilter,继承BasicAuthenticationFilter
JWTAuthenticationFilter extends BasicAuthenticationFilter {``` 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
5. 覆写doFilterInternal 方法,检查请求头有没有 Authorization 并且是不是以 Bearer开头。如果不满足条件往下一个filter传递。如果满足,从请求中获取 authentication,其会解析token,获得user,
## 实施代码
**MultiHttpSecurityConfig.groovy**
```groovy
@EnableWebSecurity
@Configuration
@Slf4j
class MultiHttpSecurityConfig {
@Configuration
@Order(1)
static class ApiSecurityConfigurationAdapter extends WebSecurityConfigurerAdapter {
@Autowired
private SysUserDetailsService sysUserDetailsService
@Autowired
private BCryptPasswordEncoder bCryptPasswordEncoder
@Bean
BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder()
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception{
auth.userDetailsService(sysUserDetailsService).passwordEncoder(bCryptPasswordEncoder)
}
protected void configure(HttpSecurity http) throws Exception {
def jWTLoginFilter=new JWTLoginFilter(authenticationManager())
jWTLoginFilter.setFilterProcessesUrl("/api/v1/login")
log.info(" === configure HttpSecurity http ===")
http
.antMatcher("/api/**")
.authorizeRequests()
.antMatchers("/api/v1/login","/api/v1/signup").permitAll()
.anyRequest().authenticated()//.hasAnyRole("ADMIN")
.and()
.addFilter(jWTLoginFilter)
.addFilter(new JWTAuthenticationFilter(authenticationManager()))
.csrf().disable()
}
}
@Configuration
@Order(2)
static class AdminSecurityConfigurationAdapter extends WebSecurityConfigurerAdapter {
protected void configure(HttpSecurity http) throws Exception {
http
.antMatcher("/h2/**")
.formLogin()
.loginPage("/auth.html")//登陆界面页面跳转URL
//.loginProcessingUrl("/fore/user/login111")//登陆界面发起登陆请求的URL
.defaultSuccessUrl("/swagger-ui.html", true)
.failureUrl("/failure.html")//登陆失败的页面跳转URL
.permitAll()//表单登录,permitAll()表示这个不需要验证
.and().logout().logoutUrl("/h2/logout")
.logoutSuccessUrl("/")
.and()//Return the SecurityBuilder
.authorizeRequests()//启用基于 HttpServletRequest 的访问限制,开始配置哪些URL需要被保护、哪些不需要被保护
.anyRequest().authenticated()
.and().headers().frameOptions().disable()//关闭X-Frame-Options,不然H2的界面会报错:in a frame because it set 'X-Frame-Options' to 'deny'
.and()
.csrf().disable()
}
}
@Configuration
@Order(3)
static class FrontConfigurationAdapter extends WebSecurityConfigurerAdapter {
protected void configure(HttpSecurity http) throws Exception {
http
.antMatcher("/**")
.formLogin()
.and()
.authorizeRequests()
.antMatchers("/index.html","/auth.html","/js/**","/css/**","/assets/**")
.permitAll()
.anyRequest().authenticated()
.and()
.headers().frameOptions().disable()
.and()
.csrf().disable()
}
}
@Configuration
@Order(4)
static class OtherSecurityConfigurationAdapter extends WebSecurityConfigurerAdapter {
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/","/code/**","/css/**", "/img/**", "/js/**").permitAll()
.and()
.csrf()
.disable()
}
}
}
JWTAuthenticationFilter
1 | /* |
JWTLoginFilter
1 | 4j |
测试步骤
配置文件中的账号密码是无效的。因为使用了自定义的认证
注册一个新用户
1
2
3
4curl -H "Content-Type: application/json" -X POST -d '{
"username": "admin",
"password": "lengbing"
}' http://localhost:18081/failure/injection/api/v1/signup登录,会返回token,在http header中,Authorization: Bearer 后面的部分就是token
1
2
3
4curl -i -H "Content-Type: application/json" -X POST -d '{
"username": "admin",
"password": "lengbing"
}' http://localhost:18081/failure/injection/api/v1/login响应结果如下:
1
2
3
4
5
6
7
8
9
10
11
12Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 55 0 0 100 55 0 585 --:--:-- --:--:-- --:--:-- 705HTTP/1.1 200
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImlhdCI6MTU3ODk4ODA0MSwiZXhwIjoxNTc4OTg4MTAxfQ.CPuFjUCjYBUcAZwLn2LKqQjQSOtBhdLs9jiGSwv8Vy4hTGwNegTOZfSBC3s8JCmPxUxqjOlyYouNmTsfPVqJwA
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 0
Date: Tue, 14 Jan 2020 07:47:21 GMT登录成功后带token访问(token就是bearer后面的字符串)
1
2
3curl -H "Content-Type: application/json" \
-H "Authorization: Bearer XXXXXX" \
"http://localhost:18081/failure/injection/api/v1/list"