Https模式下Nginx+SpringSecurity+SSO的一个交互问题

现象和问题:
有一个基于SpringBoot+Spring Security和CAS SSO的应用A,端口是8080,前端为Nginx,Nginx对外为https,即443端口,nginx内部反向代理到A就是常规的http协议了,应用A配置了正确的SSO login url和service url,历史原因,Nginx混乱的逻辑,没有配置80(http)强转443(https)。
问题来了:服务A本身运行正常,但是开启nginx前端代理时候,发现通过https进入系统A时,第一次(sso登录验证成功)通过url1总是跳转到 80端口(http)的服务,而不是443端口(https)的A应用,但是第二次再通过url1就能正常访问A应用。

怎么去解决这个问题呢?可能大部分人没看懂上文所述问题所在,也会猜到在Nginx上配置80强转443即可解决问题。
但本文希望探寻下问题的本质,以及有无其他解决办法。
先看简化且脱敏后的nginx配置
80端口为原生网页,443为A应用代理端口:

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
server {
listen 80;
server_name localhost;
location / {
root html;
index index.html index.htm;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}
}
upstream audit_config.short {
server 127.0.0.1:8080 max_fails=200 fail_timeout=10;
}
server {
listen 443 ssl;
server_name dev.example.com;
ssl_certificate /usr/local/etc/nginx/ssl/test.crt; # cert.pem;
ssl_certificate_key /usr/local/etc/nginx/ssl/test.key; # cert.key;
ssl_session_cache shared:SSL:1m;
ssl_session_timeout 5m;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
proxy_set_header Host $http_host;
location / {
proxy_pass http://audit_config.short;
}
}
include servers/*;
}

如果你对Spring Boot+Spring security+cas部分代码感兴趣,文末也附带脱敏后的代码了。
先透露下问题出在nginx的这行配置 “proxy_set_header Host $http_host”。

打开浏览器访问 https://dev.example.com/rule/index ,可以看到前面几个跳转 sso 服务器以及本地service url:https://dev.example.com/login/cas 都是正确的,即 SSO登录验证成功,访问https://dev.example.com/login/cas 也确实返回了 302 跳转链接: http://dev.example.com/rule/index ,问题就在302跳转这一步,返回302时的Location是http:// 而不是https://。

如何定位是哪一步出错?

如果把Spring Boot日志设置为debug level可能是可以的,幸而 Spring Security打印的日志足够详细,我们才能看到返回 302 条转链接相关一条log:

1
2
2019-10-28 23:27:56.268 DEBUG 50901 --- [nio-8099-exec-1] o.s.s.w.s.HttpSessionRequestCache : \
DefaultSavedRequest added to Session: DefaultSavedRequest[http://dev.example.com/rule/index]

即上面A应用返回的是http,而不是htts,是否意味着A应用的spring security cas的bug?
下面我会针对这条日志,看几种不同的解决方案。

0.

先不看即决方案,先看 http:// 是怎么来的?通过 DefaultSavedRequest 源码,可以看到http其实是 从tomcat的Request的schema取得,tomcat的Request 解析/设置 有其本身的逻辑,当Nginx通过http://协议,就决定request只能获取http的schema。不过设置 “proxy_set_header Host $http_host” 就导致tomcat解析后,当发生302跳转时拼接的Host前半部分就是Host,即http://开头。

上面日志,即Spring Security返回http是对错?有的人认为可能是bug,其实不是。Nginx和A应用之间是 http协议,也就是说,nginx传给A应用时已经向其屏蔽了客户端的https信息,如果Spring Security解析出 https的schema,那Spring Security才是真正有bug了。

故而考虑下面方法。

1. 修改Nginx到tomcat的配置

修改Nginx到tomcat的配置,把客户端的 request 信息通过 proxy_set_header方式都传给 tomcat,从而让Spring security正确解析。
这个方法是可行的,可以参考这篇博文:SSO 无法获取正确的schema
简单摘录下:

1
2
3
4
5
6
7
8
9
10
11
12
13
# nginx 配置
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
---
# tomcat 配置
<Engine >
<Valve className="org.apache.catalina.valves.RemoteIpValve"
remoteIpHeader="X-Forwarded-For"
protocolHeader="X-Forwarded-Proto"
protocolHeaderHttpsValue="https"/>
</Engine >

如果你的应用复杂,多处使用到客户端原始的 request 信息里的header等(或不仅仅在sso登录这一步使用),那么 推荐该方式,虽然 使用/配置 起来较为复杂。

2. 修改 Spring Security CAS代码

能否修改Spring Security或者 CAS代码来实现?既然nginx传给应用A时已经丢失了schema信息,那么能否通过Spring的配置信息设置正确的Location?
让我们先看看 Spring Security 在哪里生成该Location。追寻 CasAuthenticationFilter 这个CAS的SSO实现filter,可以发现在 SavedRequestAwareAuthenticationSuccessHandler 通过 DefaultRedirectStrategy 生成了 302跳转的redirectUrl:

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
public class DefaultRedirectStrategy implements RedirectStrategy {
...
public void sendRedirect(HttpServletRequest request, HttpServletResponse response,
String url) throws IOException {
String redirectUrl = calculateRedirectUrl(request.getContextPath(), url);
redirectUrl = response.encodeRedirectURL(redirectUrl);
if (logger.isDebugEnabled()) {
logger.debug("Redirecting to '" + redirectUrl + "'");
}
response.sendRedirect(redirectUrl);
}
protected String calculateRedirectUrl(String contextPath, String url) {
if (!UrlUtils.isAbsoluteUrl(url)) {
if (isContextRelative()) {
return url;
}
else {
return contextPath + url;
}
}
// Full URL, including http(s)://
if (!isContextRelative()) {
return url;
}
// Calculate the relative URL from the fully qualified URL, minus the last
// occurrence of the scheme and base context.
url = url.substring(url.lastIndexOf("://") + 3); // strip off scheme
url = url.substring(url.indexOf(contextPath) + contextPath.length());
if (url.length() > 1 && url.charAt(0) == '/') {
url = url.substring(1);
}
return url;
}
...
}

重写 sendRedirect 方法即可,即直接加个 如果redirect url以http://开头则替换为https://的逻辑。
不过由于Location是在 Spring Request里拼成的,有的同学可能会想到,那么是否可以通过只让这个 Location 以 // 开头,这样能适配http/https,即是否可以用下面方式?

1
2
3
4
5
if (url.startsWith("http://")) {
tmpUrl = tmpUrl.substring("http:".length());
}else if (url.startsWith("https://")) {
tmpUrl = tmpUrl.substring("https:".length());
}

这种方式是不可以的,org.apache.catalina.connector.Response.toAbsolute(String location) 这里实际上会对 redirectUrl 做一个schema的判断并修改为http或https。

但是考虑到,用https还是 http其实在配置 sso的 service url 时候已经可知了,所以可以根据 service url 来判断用http还是https,见下文MoreDefaultRedirectStrategy.java 部分代码。

3. 删除Nginx配置

上面两种方法都可解决问题,方案2较之方案1改动少,而且无需改nginx,但是他们其实都违背了系统设计之间单一性,增加了不必要的耦合。
如果 把 Nginx里 “proxy_set_header Host $http_host;” 这行去掉会发生什么呢?
还是开启应用A的Spring log level为debug会发现

1
2
2019-10-28 23:27:56.268 DEBUG 50901 --- [nio-8099-exec-1] o.s.s.w.s.HttpSessionRequestCache : \
DefaultSavedRequest added to Session: DefaultSavedRequest[http://audit_config.short/rule/index]

你是否感到奇怪这里返回的跳转链接是 http://audit_config.short/rule/index ?更奇怪的是返回给客户端(浏览器)竟然是正确的Location,即 https://dev.example.com/rule/index
首先注意 host 为 audit_config.short ,即配置的nginx的 upstream名字,而不是大多数人认为的 dev.example.com,
其次Nginx把 Location中的 audit_config.short 重写了。
怎么去验证这个想法呢?
nginx debug 日志打开:
1)server配置添加 error_log /path/to/log; 这一行。
2)如果是Mac brew 安装,需要 “brew install nginx –cc –with-debug”指令,记住这里要加 –cc的参数,否则不对,至少目前版本的不对。

可以看到一下nginx日志:

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
2019/10/28 19:11:16 [debug] 68616#0: *48 http upstream request: "/login/cas?ticket=ST-114933-UenJLINO5uyRLv6Mq1uA-cas01.example.org"
2019/10/28 19:11:16 [debug] 68616#0: *48 http upstream process header
2019/10/28 19:11:16 [debug] 68616#0: *48 malloc: 00007FEFE0004C00:4096
2019/10/28 19:11:16 [debug] 68616#0: *48 recv: eof:1, avail:322, err:0
2019/10/28 19:11:16 [debug] 68616#0: *48 recv: fd:11 322 of 4096
2019/10/28 19:11:16 [debug] 68616#0: *48 http proxy status 302 "302 "
2019/10/28 19:11:16 [debug] 68616#0: *48 http proxy header: "X-Content-Type-Options: nosniff"
2019/10/28 19:11:16 [debug] 68616#0: *48 http proxy header: "X-XSS-Protection: 1; mode=block"
2019/10/28 19:11:16 [debug] 68616#0: *48 http proxy header: "Cache-Control: no-cache, no-store, max-age=0, must-revalidate"
2019/10/28 19:11:16 [debug] 68616#0: *48 http proxy header: "Pragma: no-cache"
2019/10/28 19:11:16 [debug] 68616#0: *48 http proxy header: "Expires: 0"
2019/10/28 19:11:16 [debug] 68616#0: *48 http proxy header: "X-Frame-Options: DENY"
2019/10/28 19:11:16 [debug] 68616#0: *48 posix_memalign: 00007FEFE000DC00:4096 @16
2019/10/28 19:11:16 [debug] 68616#0: *48 http proxy header: "Location: http://audit_config.short/rule/index"
2019/10/28 19:11:16 [debug] 68616#0: *48 http proxy header: "Content-Length: 0"
2019/10/28 19:11:16 [debug] 68616#0: *48 http proxy header: "Date: Fri, 25 Oct 2019 11:11:16 GMT"
2019/10/28 19:11:16 [debug] 68616#0: *48 http proxy header: "Connection: close"
2019/10/28 19:11:16 [debug] 68616#0: *48 http proxy header done
2019/10/28 19:11:16 [debug] 68616#0: *48 rewritten location: "/rule/index"
2019/10/28 19:11:16 [debug] 68616#0: *48 HTTP/1.1 302
Server: nginx/1.17.3
Date: Mon, 28 Oct 2019 11:11:16 GMT
Content-Length: 0
Location: https://dev.example.com/rule/index
Connection: keep-alive
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
2019/10/28 19:11:16 [debug] 68616#0: *48 write new buf t:1 f:0 00007FEFE000DE98, pos 00007FEFE000DE98, size: 344 file: 0, size: 0
2019/10/28 19:11:16 [debug] 68616#0: *48 http write filter: l:0 f:0 s:344

也就是说 Nginx其实已经 rewrite 302的Location了,那么什么情况下会rewrite呢?

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
// ngx_http_upstream.c
static ngx_int_t
ngx_http_upstream_rewrite_location(ngx_http_request_t *r, ngx_table_elt_t *h,
ngx_uint_t offset)
{
ngx_int_t rc;
ngx_table_elt_t *ho;
ho = ngx_list_push(&r->headers_out.headers);
if (ho == NULL) {
return NGX_ERROR;
}
*ho = *h;
if (r->upstream->rewrite_redirect) {
rc = r->upstream->rewrite_redirect(r, ho, 0);
if (rc == NGX_DECLINED) {
return NGX_OK;
}
if (rc == NGX_OK) {
r->headers_out.location = ho;
ngx_log_debug1(NGX_LOG_DEBUG_HTTP, r->connection->log, 0,
"rewritten location: \"%V\"", &ho->value);
}
return rc;
}
if (ho->value.data[0] != '/') {
r->headers_out.location = ho;
}
/*
* we do not set r->headers_out.location here to avoid handling
* relative redirects in ngx_http_header_filter()
*/
return NGX_OK;
}
-----
static ngx_int_t
ngx_http_proxy_rewrite_redirect(ngx_http_request_t *r, ngx_table_elt_t *h,
size_t prefix)
{
size_t len;
ngx_int_t rc;
ngx_uint_t i;
ngx_http_proxy_rewrite_t *pr;
ngx_http_proxy_loc_conf_t *plcf;
plcf = ngx_http_get_module_loc_conf(r, ngx_http_proxy_module);
pr = plcf->redirects->elts;
if (pr == NULL) {
return NGX_DECLINED;
}
len = h->value.len - prefix;
for (i = 0; i < plcf->redirects->nelts; i++) {
rc = pr[i].handler(r, h, prefix, len, &pr[i]);
if (rc != NGX_DECLINED) {
return rc;
}
}
return NGX_DECLINED;
}

代码看下去略长,不过可以参考官方注释:
http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_redirect
即,这里仅能进行简单的 proxy_pass 逆向替换。

附:相关代码
spring-boot.version:1.5.4.RELEASE
spring-security-cas:4.2.3.RELEASE

application.properties

1
2
3
4
login.filter.type=devcas
sso.cas.servicePath=https://sso.example.com/cas
# sso.cas.localPath=http://127.0.0.1:8099/login/cas
sso.cas.localPath=https://dev.example.com/login/cas

下面是简单写的一段demo代码:

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
@Configuration
@ConditionalOnProperty(prefix = "login.filter", name = "type", havingValue = "devcas")
// @ConfigurationProperties(prefix = "sso.cas")
public class CASConfiguation {
@Value("${sso.cas.servicePath}")
private String servicePath;
@Value("${sso.cas.localPath}")
private String localPath;
@Resource CurrentUserDetailsService userDetailsService;
@Bean
public ServiceProperties serviceProperties() {
ServiceProperties serviceProperties = new ServiceProperties();
serviceProperties.setService(localPath);
// serviceProperties.setSendRenew(false);
return serviceProperties;
}
@Bean
@Primary
public AuthenticationEntryPoint authenticationEntryPoint(ServiceProperties sP) {
CasAuthenticationEntryPoint entryPoint = new CasAuthenticationEntryPoint();
entryPoint.setLoginUrl(servicePath);
entryPoint.setServiceProperties(sP);
return entryPoint;
}
@Bean
public TicketValidator ticketValidator() {
return new Cas20ServiceTicketValidator(servicePath);
}
@Bean
public CasAuthenticationProvider casAuthenticationProvider() {
CasAuthenticationProvider provider = new CasAuthenticationProvider();
provider.setServiceProperties(serviceProperties());
provider.setTicketValidator(ticketValidator());
provider.setUserDetailsService(userDetailsService);
provider.setKey("CAS_PROVIDER_LOCALHOST_9000");
return provider;
}
@Bean
public SecurityContextLogoutHandler securityContextLogoutHandler() {
return new SecurityContextLogoutHandler();
}
@Bean
public LogoutFilter logoutFilter() {
LogoutFilter logoutFilter = new LogoutFilter(servicePath+"/logout", securityContextLogoutHandler());
logoutFilter.setFilterProcessesUrl("/logout/cas");
return logoutFilter;
}
@Bean
public SingleSignOutFilter singleSignOutFilter() {
SingleSignOutFilter singleSignOutFilter = new SingleSignOutFilter();
singleSignOutFilter.setCasServerUrlPrefix(servicePath+"/logout");
singleSignOutFilter.setIgnoreInitConfiguration(true);
return singleSignOutFilter;
}
@EventListener
public SingleSignOutHttpSessionListener singleSignOutHttpSessionListener(HttpSessionEvent event) {
return new SingleSignOutHttpSessionListener();
}
}
@EnableWebSecurity(debug = true)
@Configuration
@ConditionalOnProperty(prefix = "login.filter", name = "type", havingValue = "devcas")
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private AuthenticationProvider authenticationProvider;
private AuthenticationEntryPoint authenticationEntryPoint;
private SingleSignOutFilter singleSignOutFilter;
private LogoutFilter logoutFilter;
private ServiceProperties serviceProperties;
@Autowired
public SecurityConfig(CasAuthenticationProvider casAuthenticationProvider, AuthenticationEntryPoint eP,
LogoutFilter lF, SingleSignOutFilter ssF, ServiceProperties serviceProperties) {
this.authenticationProvider = casAuthenticationProvider;
this.authenticationEntryPoint = eP;
this.logoutFilter = lF;
this.singleSignOutFilter = ssF;
this.serviceProperties = serviceProperties;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.regexMatchers("/accdenied", "/css.*", "/accdenied","/assets.*", "/favicon.ico", "/login/cas.*").permitAll()
.antMatchers("/test").authenticated()//.access("hasRole('ROLE_USER')")
.antMatchers("/secure/**").access("hasRole('ROLE_SUPERVISOR')")
.anyRequest().authenticated()
.and()
.logout()
.logoutUrl("/logout/cas")
.permitAll()
.and()
.csrf().disable();
http
.exceptionHandling().accessDeniedPage("/accdenied")
.and().httpBasic().authenticationEntryPoint(authenticationEntryPoint)
.and()
.addFilter(casAuthenticationFilter(serviceProperties))
.addFilterBefore(logoutFilter, LogoutFilter.class)
.addFilterBefore(singleSignOutFilter, CasAuthenticationFilter.class);
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.authenticationProvider(authenticationProvider);
}
@Override
protected AuthenticationManager authenticationManager() throws Exception {
return new ProviderManager(Arrays.asList(authenticationProvider));
}
@Bean
public CasAuthenticationFilter casAuthenticationFilter(ServiceProperties serviceProperties) throws Exception {
CasAuthenticationFilter filter = new CasAuthenticationFilter();
filter.setServiceProperties(serviceProperties);
filter.setAuthenticationManager(authenticationManager());
CasSuccessHandler casSuccessHandler = new CasSuccessHandler();
filter.setAuthenticationSuccessHandler(casSuccessHandler);
return filter;
}
}
class CasSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
CurrentUser userDetails = (CurrentUser) SecurityContextHolder.getContext()
.getAuthentication()
.getPrincipal();
if (userDetails != null) {
SysMenu menuRoot = userDetails.getMenuRoot();
String userName = userDetails.getSysUser().getUserName();
...
}
super.onAuthenticationSuccess(request, response, authentication);
}
}

MoreDefaultRedirectStrategy部分更改:

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
class MoreDefaultRedirectStrategy extends DefaultRedirectStrategy{
private boolean rewrite;
public void sendRedirect(HttpServletRequest request, HttpServletResponse response,
String url) throws IOException {
String redirectUrl = calculateRedirectUrl(request.getContextPath(), url);
redirectUrl = response.encodeRedirectURL(redirectUrl);
if (rewrite) {
if (redirectUrl.startsWith("http://")) {
redirectUrl = "https://" + redirectUrl.substring("http://".length());
}
}
if (logger.isDebugEnabled()) {
logger.debug("Redirecting to '" + redirectUrl + "'");
}
response.sendRedirect(redirectUrl);
}
public boolean isRewrite() {
return rewrite;
}
public void setRewrite(boolean rewrite) {
this.rewrite = rewrite;
}
}
---
// 其次需要在上述SecurityConfig里变动:
@Bean
public CasAuthenticationFilter casAuthenticationFilter(ServiceProperties serviceProperties) throws Exception {
CasAuthenticationFilter filter = new CasAuthenticationFilter();
filter.setServiceProperties(serviceProperties);
filter.setAuthenticationManager(authenticationManager());
CasSuccessHandler casSuccessHandler = new CasSuccessHandler();
MoreDefaultRedirectStrategy redirect = new MoreDefaultRedirectStrategy();
String service = serviceProperties.getService();
if (service.startsWith("https://")) {
redirect.setRewrite(true);
}
casSuccessHandler.setRedirectStrategy(redirect);
filter.setAuthenticationSuccessHandler(casSuccessHandler);
return filter;
}

outro
说一个不相干的感悟,为什么TCP必须三次握手?
在网上可以搜到答案,都很有道理,不过我想补充一下,这或许是很多人并不在乎的点,或者认为讨论三次以上意义不大。
但为什么是三次,五次不行吗?
三次其实就是请求确认->确认->对确认的确认,如果从严格的科学理论上讲,这可能是不够的,一个无限循环,但是从技术上讲,也就是涉及经验(当然也综合考虑了性能/效率等因素)。
超过三次就强制认为失败而已。
计算机技术并没有大家想象的那么严谨,甚至可能会有 0.3不等于0.3的情况,如果大学了解过一点数电和模电的知识,就会知道这种区别,如果再学过物理理论的对立面–物理实验,就会理解 误差/精确度 的含义。
同时也会知道,流行大众以及电影上的“蝴蝶效应”其实算是个笑话吧。但这并不表示误差就不重要,实际上,上个世纪最伟大的物理理论,跟人们对于极小误差的纠结源远很深呢。