cors--springboot shiro 前后端分离跨域

前言

前后端分离,采用stateless JWT,服务器就无法控制客户端的登录,而stateful jwt本质上跟session一样,所以采用shiro的session管理,简单方便。由此涉及后续的session的管理、跨域、shiro跳转等问题。

跨域的两种方案

沿用Cookie

沿用传统的cookie session方式,就需要前后端分别设置,可以让前端的request带上后端域名对应的cookie

前端

客户端需要设置Ajax请求属性withCredentials 为true,让Ajax请求都带上Cookie

1
2
3
4
5
6
7
8
9
10
$.ajax({
url:url,
type:"GET",
xhrFields:{
withCredentials:true
},
success:function(res){
console.log(res);
}
})
后端

首先服务端在使用cors协议时需要设置响应消息头Access-Control-Allow-Credentials的值为true*,即允许在ajax访问时携带cookie(如上,前端也要设置withCredentials为true)。另外为了安全,在cors标准里不允许Access-Control-Allow-Origin设置为,而是必须指定明确的、与请求网页一致的域名,cookie也依然遵循“同源策略”,只有用目标服务器域名设置的cookie才会上传,而且使用document.cookie也无法读取目标服务器域名下的cookie。接下来我们来看看实现:

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
class CorsFilter : Filter {

companion object{
private var logger = LoggerFactory.getLogger(this.javaClass)
}

override fun init(filterConfig: FilterConfig){

}

override fun doFilter(request: ServletRequest?, response: ServletResponse?, chain: FilterChain?) {
var httpresponse = response as HttpServletResponse
var httprequest = request as HttpServletRequest
// 允许哪些Origin发起跨域请求
var orgin = request . getHeader ("Origin")
//header有Origin字段,说明客户端跨域
if (!StringUtils.isEmpty(orgin)){
]
httpresponse.setHeader("Access-Control-Allow-Origin", orgin) // 此时,不允许为*
// 允许请求的方法
httpresponse.setHeader("Access-Control-Allow-Methods", httprequest.method)
//多少秒内,不需要再发送预检验请求,可以缓存该结果
httpresponse.setHeader("Access-Control-Max-Age", "3600")
// 表明它允许跨域请求包含xxx头
httpresponse.setHeader("Access-Control-Allow-Headers",httprequest.getHeader("Access-Control-Request-Headers"))
//是否允许浏览器携带用户身份信息(cookie)
httpresponse.setHeader("Access-Control-Allow-Credentials", "true")
}
//prefight请求
if (request.getMethod().equals(RequestMethod.OPTIONS.name)) {
// logger.info("处理prefight请求,请求URl:${httprequest.requestURL}")
response.setStatus(HttpStatus.OK.value());
return;
}

chain?.doFilter(request, response);
}

override fun destroy() {

}


}

自定义http header

绕过cookie,自定义http header传输token(本项目token即sessionId),这种方式适合前端不支持cookie的时候,具体设置在后文中。

session管理

采用第二种方式,就需要绕过传统的sessionid传输方式:通过请求头里的cookie传输(浏览器的cookie是跟着域名走的)。客户端登录,服务端通过JSON返回sessionid,客户端保存,后续每次请求要在请求头中添加加token字段,服务端shiro根据请求头的token字段获取sessionid。
shiroConfiguration中的securityManager配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Bean
fun securityManager() : DefaultWebSecurityManager{
val manager = DefaultWebSecurityManager()
manager.setRealm(getRealm())
manager.sessionManager = sessionManager()
SecurityUtils.setSecurityManager(manager)
return manager
}


@Bean
fun sessionManager(): SessionManager? {
val mySessionManager = MySessionManager()
mySessionManager.setSessionDAO(sessionDAO())
return mySessionManager
}

主要是在MySessionManager里重写DefaultWebSessionManager的getSessionId方法和retrieveSession方法,如下:

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
package com.example.customer.config.shiro

import com.example.customer.util.StringUtils
import org.apache.shiro.session.Session
import org.apache.shiro.session.UnknownSessionException
import org.apache.shiro.session.mgt.SessionKey
import org.apache.shiro.web.servlet.ShiroHttpServletRequest
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager
import org.apache.shiro.web.util.WebUtils
import org.slf4j.LoggerFactory
import org.springframework.context.annotation.Configuration
import java.io.Serializable
import java.lang.Boolean
import javax.servlet.ServletRequest
import javax.servlet.ServletResponse

@Configuration
class MySessionManager : DefaultWebSessionManager{

constructor() : super()

companion object{
private var log = LoggerFactory.getLogger(this.javaClass.name)
private val AUTHORIZATION = "auth-token"
private val HEADER_SESSION_ID_SOURCE = "header request"
private val MY_SESSION_ATTRIBUTE = "MY_SESSION_ATTRIBUTE"
}


protected override fun getSessionId(request: ServletRequest, response: ServletResponse?): Serializable? {
val id = WebUtils.toHttp(request).getHeader(AUTHORIZATION)
//如果请求头中有 Authorization 字段, 则其值为sessionId
return if (!StringUtils.isEmpty(id)) {
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, HEADER_SESSION_ID_SOURCE)
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, id)
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE)
id
} else {
//否则按默认规则从cookie取sessionId
super.getSessionId(request, response)
}
}

@Throws(UnknownSessionException::class)
protected override fun retrieveSession(sessionKey: SessionKey?): Session? {
val sessionId = getSessionId(sessionKey) ?: return null
val request: ServletRequest = WebUtils.getRequest(sessionKey)
return if (request.getAttribute(MY_SESSION_ATTRIBUTE) != null) {
log.debug("Get Session from request!")
request.getAttribute(MY_SESSION_ATTRIBUTE) as Session
} else {
log.debug("Get Session from redis!")
val s: Session = retrieveSessionFromDataSource(sessionId)
if (s == null) {
//session ID was provided, meaning one is expected to be found, but we couldn't find one:
val msg = "Could not find session with ID [$sessionId]"
throw UnknownSessionException(msg)
}
request.setAttribute(MY_SESSION_ATTRIBUTE, s)
s
}
}
}

跨域设置

简单请求

浏览器将CORS请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。
对于简单请求,浏览器直接在ruquest头之中,增加一个Origin字段,相应地,服务器会在response头中添加Access-Control-Allow-Origin等字段,如此便是一次成功的跨域请求。

upload successful
只要同时满足以下两大条件,就属于简单请求。

请求方法是以下三种方法之一:

1
2
3
HEAD
GET
POST

HTTP的头信息不超出以下几种字段:

1
2
3
4
5
Accept
Accept-Language
Content-Language
Last-Event-ID
Content-Type: 只限于三个值 application/x-www-form-urlencoded

凡是不同时满足上面两个条件,就属于非简单请求。

预检请求

对于非简单请求,浏览器会在正式通信之前,增加一次HTTP查询请求,称为”预检”请求(preflight),预检的请求方法(Request Method)为OPTION。
浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest请求,否则就报错。

解决方案

自定义过滤器,针对请求头中有Origin字段的,response头中添加相应字段,针对OPITION请求,response返回200状态码。
新建CorsFilter类:

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
package com.example.customer.config

import com.example.customer.util.StringUtils
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.RequestMethod
import javax.servlet.*
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse


//@WebFilter(urlPatterns = arrayOf("/*"),filterName = "crosFilter")
class CorsFilter : Filter {

companion object{
private var logger = LoggerFactory.getLogger(this.javaClass)
}

override fun init(filterConfig: FilterConfig){

}

override fun doFilter(request: ServletRequest?, response: ServletResponse?, chain: FilterChain?) {
var httpresponse = response as HttpServletResponse
var httprequest = request as HttpServletRequest
// 允许哪些Origin发起跨域请求
var orgin = request . getHeader ("Origin")
//header有Origin字段,说明客户端跨域
if (!StringUtils.isEmpty(orgin)){
logger.info("处理跨域请求,请求URl:${httprequest.requestURL}")
httpresponse.setHeader("Access-Control-Allow-Origin", orgin)
// 允许请求的方法
httpresponse.setHeader("Access-Control-Allow-Methods", httprequest.method)
//多少秒内,不需要再发送预检验请求,可以缓存该结果
httpresponse.setHeader("Access-Control-Max-Age", "3600")
// 表明它允许跨域请求包含xxx头
httpresponse.setHeader("Access-Control-Allow-Headers",httprequest.getHeader("Access-Control-Request-Headers"))
//是否允许浏览器携带用户身份信息(cookie)
httpresponse.setHeader("Access-Control-Allow-Credentials", "true")
}
//prefight请求
if (request.getMethod().equals(RequestMethod.OPTIONS.name)) {
logger.info("处理prefight请求,请求URl:${httprequest.requestURL}")
response.setStatus(HttpStatus.OK.value());
return;
}

chain?.doFilter(request, response);
}

override fun destroy() {

}


}

建立WebFilterConfig类:

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
package com.example.customer.config

import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import javax.servlet.Filter


/**
* 1:自定义过滤器,可以采取Filter加@WebFilter和启动类加@ServletComponentScan的方式,过滤器的执行顺序按照类名排序
* 故而采用如下FilterRegistrationBean的方式,可以自定义顺序
*/
@Configuration
class WebFilterConfig{

@Bean
fun crosResFilter(): FilterRegistrationBean<*>? {
val filterRegistrationBean: FilterRegistrationBean<Filter?> = FilterRegistrationBean<Filter?>()
val corsFilter = CorsFilter()
filterRegistrationBean.setFilter(corsFilter)
filterRegistrationBean.addUrlPatterns("/*") //配置过滤规则
filterRegistrationBean.setName("corsFilter") //设置过滤器名称
filterRegistrationBean.order = 1 //执行次序
return filterRegistrationBean
}
}

由此便能愉快的跨域访问了。

shiro跳转

由于shiro对前后端分离支持不是很理想,如访问需认证的路径,若未登录会直接跳转至登录页面(默认是/login.jsp),这中情况我们需要直接返回未认证的JSON数据,由前端控制路由。
新建MyFormAuthenticationFilter,继承shiro的FormAuthenticationFilter(对应过滤authc的路径)

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
package com.example.customer.config.shiro

import com.example.customer.util.JsonUtils
import com.example.customer.util.constants.ErrorEnum
import org.apache.shiro.web.filter.authc.FormAuthenticationFilter
import org.slf4j.LoggerFactory
import javax.servlet.ServletRequest
import javax.servlet.ServletResponse
import javax.servlet.http.HttpServletResponse

class MyFormAuthenticationFilter : FormAuthenticationFilter {

constructor() : super()

companion object {
private val log = LoggerFactory.getLogger(this.javaClass)
}


@Throws(Exception::class)
override fun onAccessDenied(request: ServletRequest?, response: ServletResponse?): Boolean {
return if (isLoginRequest(request, response)) {
if (isLoginSubmission(request, response)) {
if (log.isTraceEnabled) {
log.trace("Login submission detected. Attempting to execute login.")
}
executeLogin(request, response)
} else {
if (log.isTraceEnabled) {
log.trace("Login page view.")
}
//allow them to see the login page ;)
true
}
} else {
var resp = response as HttpServletResponse

if (log.isTraceEnabled()) {
log.trace("Attempting to access a path which requires authentication. Forwarding to the " +
"Authentication url [" + getLoginUrl() + "]");
}
//不再跳转,直接返回Json信息
resp.setContentType("application/json; charset=utf-8");
resp.setCharacterEncoding("UTF-8");
resp.getWriter().write(JsonUtils.errorJson(ErrorEnum.E_401).toString())
false
}
}
}

在shiro过滤链中,添加自定义过滤器

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

@Configuration
class ShiroConfiguration{

/**
* shiro过滤器
*/
@Bean
fun shiroFilter(manager: SecurityManager) : ShiroFilterFactoryBean{
val factoryBean = ShiroFilterFactoryBean()
//设置securityManager
factoryBean.securityManager = manager
//自定义过滤器,修改认证失败跳转
val filters = mutableMapOf<String,Filter>()
filters.put("myauthc",myFormAuthenticationFilter())
factoryBean.filters = filters

val filterChainDefinitionMap: MutableMap<String, String> = LinkedHashMap()
filterChainDefinitionMap["/static/**"] = "anon" // 静态资源匿名访问
filterChainDefinitionMap["/login"] = "anon" // 登录匿名访问
filterChainDefinitionMap["/login/doLogin"] = "anon" // 登录匿名访问
filterChainDefinitionMap["/login/logout"] = "logout" // 用户退出,只需配置logout即可实现该功能
filterChainDefinitionMap["/**"] = "myauthc" // 其他路径均需要身份认证,一般位于最下面,优先级最低
// 如果不设置默认会自动寻找Web工程根目录下的"/login.jsp"页面
//身份认证失败,不直接Shiro跳转至默认登录页,而是跳转至未认证接口,返回Json数据,前后端分离中登录界面跳转应由前端路由控制

// factoryBean.setLoginUrl("/login/unauth");
// 权限认证失败,跳转后续处理
// factoryBean.setUnauthorizedUrl("");
factoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap)

return factoryBean
}


private fun myFormAuthenticationFilter(): MyFormAuthenticationFilter{
return MyFormAuthenticationFilter()
}


/**
*securityManager配置
* 不指定名字的话,自动创建一个方法名第一个字母小写的bean
*/
@Bean
fun securityManager() : DefaultWebSecurityManager{
val manager = DefaultWebSecurityManager()
manager.setRealm(getRealm())
SecurityUtils.setSecurityManager(manager)
return manager
}
}