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

2,859 阅读8分钟
原文链接: thomaslau.xyz

现象和问题:
有一个基于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的情况,如果大学了解过一点数电和模电的知识,就会知道这种区别,如果再学过物理理论的对立面–物理实验,就会理解 误差/精确度 的含义。
同时也会知道,流行大众以及电影上的“蝴蝶效应”其实算是个笑话吧。但这并不表示误差就不重要,实际上,上个世纪最伟大的物理理论,跟人们对于极小误差的纠结源远很深呢。