现象和问题:
有一个基于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应用代理端口:
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”。
打开浏览器访问 dev.example.com/rule/index ,可以看到前面几个跳转 sso 服务器以及本地service url:dev.example.com/login/cas 都是正确的,即 SSO登录验证成功,访问dev.example.com/login/cas 也确实返回了 302 跳转链接: dev.example.com/rule/index ,问题就在302跳转这一步,返回302时的Location是http:// 而不是https://。
如何定位是哪一步出错?
如果把Spring Boot日志设置为debug level可能是可以的,幸而 Spring Security打印的日志足够详细,我们才能看到返回 302 条转链接相关一条log:
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
简单摘录下:
# 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:
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,即是否可以用下面方式?
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会发现
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]
你是否感到奇怪这里返回的跳转链接是 audit_config.short/rule/index ?更奇怪的是返回给客户端(浏览器)竟然是正确的Location,即 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日志:
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呢?
// 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;
}
代码看下去略长,不过可以参考官方注释:
nginx.org/en/docs/htt…
即,这里仅能进行简单的 proxy_pass 逆向替换。
附:相关代码
spring-boot.version:1.5.4.RELEASE
spring-security-cas:4.2.3.RELEASE
application.properties
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代码:
@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部分更改:
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的情况,如果大学了解过一点数电和模电的知识,就会知道这种区别,如果再学过物理理论的对立面–物理实验,就会理解 误差/精确度 的含义。
同时也会知道,流行大众以及电影上的“蝴蝶效应”其实算是个笑话吧。但这并不表示误差就不重要,实际上,上个世纪最伟大的物理理论,跟人们对于极小误差的纠结源远很深呢。