失恋的酱肘子 · Neo4j 入门 - Iawen's ...· 昨天 · |
不爱学习的番茄 · WebAPI公开接口请求签名验证 - ...· 昨天 · |
不爱学习的伏特加 · GitHub-CVE与工具监控 | ...· 13 小时前 · |
侠义非凡的闹钟 · TCL中环吹响了硅片洗牌号角 - ...· 2 月前 · |
茫然的春卷 · 快穿之恣意妄为 ...· 6 月前 · |
留胡子的热带鱼 · TNSS-086搜索_TNSS086网盘搜索 ...· 7 月前 · |
没人理的路灯 · 房屋交付10年没办房产证 ...· 1 年前 · |
豪气的枕头 · 8万名球迷鸟巢狂欢 ...· 1 年前 · |
Spring Security已经集成的认证技术:
除此之外,Spring Security还引入了一些第三方包,用于支持更多的认证技术,如JOSSO等。如果所有这些技术都无法满足需求,则Spring Security允许我们编写自己的认证技术。因此,在绝大部分情况下,当我们有Java应用安全方面的需求时,选择Spring Security往往是正确而有效的。
在授权上,Spring Security不仅支持基于URL对Web的请求授权,还支持方法访问授权、对象访问授权等,基本涵盖常见的大部分授权场景。
1 |
<dependency> |
点进spring-boot-starter-security,可以看到其依赖
1 |
<dependencies> |
1 |
package com.louris.springboot; |
进入
localhost:8080
后跳出登录页面验证,默认用户名为user, 密码为动态生成的,需要查看后台控制台信息。验证通过后才进入页面。
可以通过配置文件设置:
1 |
spring.security.user.name=user |
1 |
package com.louris.springboot.config; |
其中WebSecurityConfiguerAdapter类中的方法已经声明了一些安全特性:
1 |
protected void configure(HttpSecurity http) throws Exception { |
重新刷新的话,直接进入页面了,没有表单验证,需要清理缓存。
1 |
package com.louris.springboot.config; |
将页面放在
resources/static
下
1 |
<!DOCTYPE html> |
1 |
package com.louris.springboot.config; |
1 |
package com.louris.springboot.controller; |
1 |
package com.louris.springboot.controller; |
1 |
package com.louris.springboot.controller; |
?
匹配任意单个字符,使用
*
匹配0或任意数量的字符,使用
**
匹配0或者更多的目录
antMatchers("/admin/api/**")
相当于匹配了
/admin/api
下所有API,此处我们指定当其必须为
ADMIN
角色时才能访问,其他同理;
/app/api
下的API会调用
permitAll()
公开其权限
1 |
package com.louris.springboot.config; |
启动运行后,可以发现:
localhost:8080/app/api/hello
可以正常访问
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 |
@Autowired |
createUser
函数相当于执行SQL语句
insert into users(username, password, enabled) values(?,?,?)
;
启动后,可以看到数据库中已经插入了对应数据,
authorities
表的authority字段存放的是前面设定的角色,只是会被添加上”ROLE_”前缀。
或者重写重载函数
1 |
@Override |
1 |
// |
1 |
// |
1 |
create table users( |
1 |
package com.louris.springboot.bean; |
实现UserDetails定义的几个方法:
1 |
package com.louris.springboot.mapper; |
1 |
package com.louris.springboot.impl; |
在验证用户名和密码之前,引入辅助验证可有效防范暴力试错,图形验证码就是简单且行之有效的一种辅助验证方式。
1 |
@Override |
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 |
<!DOCTYPE html> |
1 |
package com.louris.springboot.config; |
上一节使用过滤器的方式实现了带图形验证码的验证功能,属于Servlet层面,简单、易理解。其实,Spring Security还提供了一种更优雅的实现图形验证码的方式,即自定义认证。
1 |
// |
1 |
// |
1 |
// |
1 |
// |
Spring Security提供了多种常见的认证技术,包括但不限于以下几种:
1 |
// |
1 |
// |
1 |
package com.louris.springboot.bean; |
1 |
// |
1 |
// |
1 |
// |
1 |
package com.louris.springboot.bean; |
1 |
package com.louris.springboot.bean; |
1 |
package com.louris.springboot.bean; |
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 |
package com.louris.springboot.config; |
1 |
public abstract class AbstractRememberMeServices implements RememberMeServices, InitializingBean, LogoutHandler, MessageSourceAware { |
1 |
// |
其中,在没有指定时,key是一个UUID字符串
1 |
private String getKey(){ |
这将导致每次重启服务后,key都会重新生成,使得重启之前的所有自动登录cookie失效。除此之外,在多实例部署的情况下,由于实例间的key并不相同,所以当用户访问系统的另一个实例时,自动登录策略就会失效。合理的用法是指定key。
1 |
@Autowired |
series
和
token
两个值,它们都是用MD5散列过的随机字符串。不同的是,
series
仅在用户使用密码重新登录时更新,而token会在每一个新的session中都重新生成。
这样设计的好处:
series
变更,而每次自动登录都需要同时验证
series
和
token
两个值,当该令牌还未使用过自动登录就被盗取时,系统会在非法用户验证通过后刷新
token
值,此时在合法用户的浏览器汇总,该
token
值已经失效。当合法用户使用自动登录时,由于该
series
对应的
token
不同,系统可以推断该令牌可能已被盗用,从而做一些处理。例如,清理该用户的所有自动登录令牌,并通知用户可能已被盗号等。
Spring Security使用PersistentRememberMeToken来表明一个验证实体
1 |
// |
需要传入一个PersistentTokenRepository实例,PersistentTokenRepository实例定义了持久化令牌的一些必要方法:
1 |
// |
既可以按照自己的方式实现PersistentTokenRepository接口,也可以使用Spring Security提供的JDBC方案实现
具体通过实现类实现:
1 |
// |
对应的,需要在数据库中新建一张
persistent_logins
表
1 |
create table persistent_logins( |
1 |
package com.louris.springboot.config; |
如有必要,还可以重新配置。
1 |
package com.louris.springboot.config; |
实际上,logout的清理过程是由多个LogoutHandler流式处理的。
1 |
// |
在Logout过滤器中可以理顺整个注销的处理流程。
1 |
// |
1 |
// |
session的诞生解决了这个难题,服务器通过与用户约定每个请求携带一个id类的信息,从而让不同请求之间有了关联,而id又可以很方便地绑定具体用户,所以我们可以把不同请求归类到同一用户。
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是一个会话管理的配置器,其中,防御会话固定攻击的策略有四种:
默认已经启用
migrateSession
策略,如有必要,可以做出修改:
1 |
@Override |
在Spring Security中,即便没有配置,也大可不必担心会话固定攻击。这是因为Spring Security的HTTP防火墙会帮助我们拦截不合法的URL,当我们试图访问带session的URL时,实际上会被重定向到错误页。
localhost:8080/user/api/hello;jsessionId=xxx
除防御会话固定攻击外,还可以通过Spring Security配置一些会话过期策略。
1 |
@Override |
1 |
@Override |
1 |
# 单位为秒 |
TomcatServletWebServerFactory
中
1 |
private long getSessionTimeoutInMinutes() { |
固定会话攻击和会话过期策略都很简单,在Spring Security中,会话管理最完善的是会话并发控制,但会话并发控制存在一些用法陷阱,应当多加注意。
1 |
@Override |
/logout
),理论上应该可以继续登录了,但很遗憾,Spring Security依然提示我们超过了最大会话数。
1 |
@Bean |
需要在User类中重写
equals
和
hashCode
方法,理由稍后。
1 |
package com.louris.springboot.bean; |
1 |
package com.louris.springboot.impl; |
1 |
// |
1 |
// |
1 |
// |
大部分的集群部署采用的网络结构:
前端PC、手机的请求首先会打在LB(Load Balance 负载均衡,常见的有Nignx、HAProxy)服务器上,LB服务器再根据负载策略将这些请求转发至后面的服务,以达到请求分散的目的。正常来说,在集群环境下,同个用户的请求可能会被分发到不同的服务器上,假如登录操作时在SERVER1完成的,即SERVER1缓存了用户的登录状态,但SERVER2和SERVER3并不知情,如果该用户的后续操作被分配到了SERVER2或SERVER3上,这时就会要求该用户重新登录,这就是典型的
会话状态集群不同步问题
。
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 Security提供的会话并发控制是基于内存实现的,在集群部署时如果想要使用会话并发控制,则必须进行适配。
session共享,本质上就是存储容器的变动,但如何得到最优存取结构、如何准确清理过期会话,以及如何整合WebSocket等无法回避。Spring Session就是专门用于解决集群会话问题的,它不仅为集群会话提供了非常完善的支持,与Spring Security的整合也有专门的实现。
Spring Session支持多种类型的存储容器,包括Redis、MongoDB等。由于以下的整合都是基于Redis的。
1 |
<!--spring session核心依赖--> |
1 |
# 配置redis |
1 |
package com.louris.springboot.config; |
1 |
package com.louris.springboot.config; |
潜在风险:
对应策略:
不可逆散列算法:
可逆加密算法:
Spring Security内置了密码加密机制,只需使用一个
PasswordEncoder
接口即可。
1 |
// |
encode
和
matches
两个方法,当用户数据库存储用户密码时,加密过程用
encode
方法,
matches
方法用于判断用户登录时输入的密码是否正确;
PasswordEncoder
接口,例如,
StandardPasswordEncoder
中的常规摘要算法(SHA-256等)、
BCryptPasswordEncoder
加密,以及类似
BCrypt
的慢散列加密
Pbkdf2PasswordEncoder
等,官方推荐使用
BCryptPasswordEncoder
。
1 |
package com.louris.springboot.bean; |
运行两次,可以发现同一个密码加密后的密文不一致,所以不能被破解,但是通过
matches
可以发现密码一致!
1 |
@Bean |
通常情况下,在新系统中使用BCrypt加密不需要考量太多,但老系统由于存在大量旧数据,草率接入会导致老用户无法登录,需要自定义实现。
1 |
package com.louris.springboot.bean; |
再进一步,如果我们不仅想要兼容,还想将不安全的旧密码无缝修改成BCrypt密文,如何操作?
(1)如果旧密码都是未经任何加密的明文,也许“跑库”修改是非常好的一种选择,但并非所有系统都有这么理想的状态;
(2)如果旧密码都是被散列加密过的,那么可以采用下面两种方法解决:
① 使用增量更新的方法。当用户输入的密码正确时,判断数据库中的密码是否为BCrypt密文,如果不是,则尝试使用用户输入的密码生成BCrypt密文并写回数据库;
② 以旧的加密方案作为基础接入BCrypt加密。例如,旧的方案是MD5加密,即数据库中的所有密码都是MD5形式的密码,那么直接把这些密码当作明文,先“跑库”生成BCrypt密文,再使用encode和matches两个方法在执行BCrypt加密之前都先用MD5运算一遍即可。
跨域是一种浏览器同源安全策略,即浏览器单方面限制脚本的跨域访问。
此外,我们平常所说的跨域实际上都是在讨论浏览器行为,包括各种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需要后端参与。
例如,一个用于获取用户列表的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新增的HTTP首部字段由服务器控制,常用首部字段如下:
(1)
Access-Control-Allow-Origin
:
<origin>
或
*
。
<origin>
指被允许的站点,使用URL首部匹配原则。
*
匹配所有站点,表示允许来自所有域的请求。但并非所有情况都简单设置即可,如果需要浏览器在发起请求携带凭证信息,则不允许设置为
*
。
Vary
字段还需要携带
Origin
属性,因为服务器对不同的域会返回不同的内容:
1 |
Access-Control-Allow-Origin: http://bluroo.com |
Access-Control-Allow-Headers
字段仅在预检请求的响应中指定有效,用于表明服务器允许跨域的HTTP方法,多个方法之间用逗号隔开。
Access-Control-Allow-Headers
字段仅在预检请求的响应中指定有效,用于表明服务器允许携带的首部字段。多个首部字段之间用逗号隔开。
Access-Control-Max-Age
字段用于指明本次预检请求的有效期,单位为秒。在有效期内,预检请求不需要再次发起。
Access-Control-Allow-Credentials
字段取值为
true
时,浏览器会在接下来的真实请求中携带用户凭证信息(cookie等),服务器也可以使用
Set-Cookie
向用户浏览器写入新的cookie。注意,使用
Access-Control-Allow-Credentials
时,
Access-Control-Allow-Origin
不应该设置为
*
。
总体来说,CORS是一种更安全的官方跨域解决方案,它依赖于浏览器和后端,即当需要用CORS来解决跨域问题时,只需要后端做出支持即可。前端在使用这些域时,基本等同于访问同源站点资源。注意,CORS不支持IE8以下版本的浏览器。
在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请求到目标站点,以查明该请求是否安全,防止请求对目标站点的数据造成破坏。
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,否则浏览器会拦截,并抛出错误。
1 |
@Override |
1 |
// |
CSRF(Cross Site Request Forgery),可译为跨域请求伪造,是一种利用用户带登录态的cookie进行安全操作的攻击方式。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漏洞,例如,CSRFTester。
在任何情况下,都应当尽可能地避免以GET方式提供涉及数据修改的API 。在此基础上,防御CSRF攻击的方式主要有以下两种。
HTTP Referer是由浏览器添加的一个请求头字段,用于标识请求来源,通常用在一些统计相关的场景,浏览器端无法轻易篡改该值。
回到前面构造POST请求实行CSRF攻击的场景,其必要条件就是诱使用户跳转拿到第三方页面,在第三方页面构造发起的POST请求中,HTTP Referer字段不是银行的URL(少部分老版本的IE浏览器可以调用API进行伪造,但最后的执行逻辑是放在用户浏览器上的,只要用户的浏览器版本较新,便可以避免这个问题),当校验到请求来自其他站点时,可以认为是CSRF攻击,从而拒绝该服务。
当然,这种方式简单便捷,但并非完全可靠 。除前面提到的部分浏览器可以篡改HTTP Referer外,如果用户在浏览器中设置了不被跟踪,那么HTTP Referer字段就不会自动添加,当合法用户访问时,系统会认为是CSRF攻击,从而拒绝访问。
CSRF是利用用户的登录态进行攻击的,而用户的登录态记录在cookie中。其实攻击者并不知道用户的cookie存放了哪些数据,于是想方设法让用户自身发起请求,这样浏览器便会自行将cookie传送到服务器完成身份校验。
CsrfToken的防范思路是,添加一些并不存放于cookie的验证值,并在每个请求中都进行校验,便可以阻止CSRF攻击。
具体做法是在用户登录时,由系统发放一个CsrfToken值,用户携带该CsrfToken值与用户名、密码等参数完成登录。系统记录该会话的CsrfToken值,之后在用户的任何请求中,都必须带上该CsrfToken值,并由系统进行校验。
这种方法需要与前端配合,包括存储CsrfToken值,以及在任何请求中(包括表单和Ajax)携带CsrfToken值。安全性相较于HTTP Referer提高很多,但也存在一定的弊端。例如,在现有的系统中进行改造时,前端的工作量会非常大,几乎要对所有请求进行处理。如果都是XMLHttpRequest,则可以统一添加CsrfToken值;但如果存在大量的表单和a标签,就会变得非常繁琐。因此建议在系统开发之初考虑如何防御CSRF攻击。
CSRF攻击完全是基于浏览器进行的,如果我们的系统前端并非在浏览器中运作,就应当关闭CSRF。
Spring Security通过注册一个CsrfFilter来专门处理CSRF攻击。
CsrfToken是一个用于描述Token值,以及验证时应当获取哪个请求参数或请求头字段的接口。
1 |
// |
CsrfTokenRepository则定义了如何生成、保存以及加载CsrfToken。
1 |
// |
在默认情况下,Spring Security加载的是一个HttpSessionCsrfTokenRepository。
1 |
// |
当使用HttpSessionCsrfTokenRepository时,前端必须用服务器渲染的方式注入CsrfToken值,例如jsp标签。
1 |
<c:url value="/login" var="loginUrl"/> |
这种方式在某些单页应用中局限性较大,灵活性不足。
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值并没有被校验的作用,仅仅作为一个存储容器使用。
1 |
// |
于是Spring Security新增了LazyCsrfTokenRepository,用来延时保存CsrfToken值(允许创建,但只有真正使用时才会被保存)。
1 |
// |
1 |
public final class CsrfConfigurer<H extends HttpSecurityBuilder<H>> extends AbstractHttpConfigurer<CsrfConfigurer<H>, H> { |
1 |
@Override |
单点登录(Single Sign On, SSO)是指在多个应用系统中,只需登录一次,即可同时以登录态共享企业所有相关又彼此独立的系统的功能。对于旗下拥有众多系统的企业来说,单点登录不仅降低了用户的登录成本,统一了不同系统间的账号体系,还减少了各个系统在用户设计上付出的精力。
设置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"); |
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(Central Authentication Service,中央验证服务),利用CAS实现单点登录将大大降低开发及维护成本。
CAS由CAS Server和CAS Client两部分组成:
CAS的三个重要术语:
为了验证CAS单点登录,需要在本地开发环境中搭建一套用于测试的CAS Server
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 Server
mvn spring-boot:run
其他略
在实现CAS Client过程中,先梳理当前准备好的两个域名信息:
1 |
<dependency> |
1 |
## CAS Client的相关配置信息 |
1 |
package com.louris.springboot.config; |
1 |
@EnableWebSecurity(debug = true) //里面已经加入了@Configuration |
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基本认证相比要复杂得多,其涉及的参数如下:
对于服务器而言,最重要的字段是nonce;对于客户端而言,最重要的字段是response
nonce是由服务器生成的随机字符串,包含过期时间和密钥。在Spring Security中,其生成算法如下:
base64(expirationTime + ":" + md5(expirationTime + ":" + key))
其中,expirationTime默认为300s,在DigestAuthenticationEntryPoint中可以找到Spring Security发送“挑战”数据的过程。
1 |
// |
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注解就可以让Spring Security起作用?Spring Security又是通过什么方式来拦截请求并执行认证的?
1 |
// |
开放授权(Open Authorization, OAuth)是一种资源提供商用于授权第三方应用代表 资源所有者获取有限访问权限的授权机制。
流程:
(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为申请的权限范围,如获取用户信息、获取用户相册等,由授权服务器抽象为具体的条目;
code为申请访问令牌必备的授权码(有效期较短,注意与访问令牌的区别)。客户端拿到code之后需要向授权服务器申请访问令牌(仅可使用一次,用完作废);
2.隐式授权模式(Implicit)
隐式授权模式的客户端一般是指用户浏览器。访问令牌通过重定向的方式传递到用户浏览器中,再通过浏览器的JavaScript代码来获取访问令牌。由于访问令牌直接暴露在浏览器端,所以隐式授权模式可能会导致访问令牌被黑客获取,仅适用于需要临时访问的场景。
3.密码授权模式(Password Credentials)
客户端直接携带用户的密码向授权服务器申请令牌。这种登录操作不再像前两种授权模式一样跳转到授权服务器进行而是由客户端提供专用页面。如果用户信任该客户端(通常为信誉度高的著名公司),用户便可以直接提供密码, 客户端在不存储用户密码的前提下完成令牌的申请。
4.客户端授权模式(Client Credentials)
客户端授权模式实际上并不属于OAuth的范畴,因为它的关注点不再是用户的私有信息或数据,而是一些由资源服务器持有但并非完全公开的数据,如微信的公众平台授权等。
客户端授权模式通常由客户端提前向授权服务器申请应用公钥、密钥,并通过这些关键信息向授权服务器申请访问令牌,从而得到资源服务器提供的资源。