# CAS和Shiro内外网访问

由于有需求要实现内外网双IP访问同一个应用,但是当前已部署的应用使用的cas+shiro的跳转url在spring的配置xml中写死的,所以需要实现判断来源HOST动态单点登录和跳转

部分响应体中写死了IP需要替换

使用Nginx+Lua脚本,body_filter_by_lua* (opens new window)替换响应体中的IP,也可以使用Nginx内置模块ngx_http_sub_filter_module (opens new window),或第三方模块replace-filter-nginx-module (opens new window)

因为替换内容后长度不一致了,需要在header_filter_by_lua*中加入ngx.header.content_length = nil置空内容长度

-- body_filter_by_lua_file:
-- 获取当前响应数据
local chunk, eof = ngx.arg[1], ngx.arg[2]
local cjson = require("cjson");

local req_headers = ngx.req.get_headers() -- 请求头
local resp_headers = ngx.resp.get_headers() -- 响应头

-- 定义全局变量,收集全部响应
if ngx.ctx.buffered == nil then
    ngx.ctx.buffered = {}
end

-- 如果非最后一次响应,将当前响应赋值
if chunk ~= "" and not ngx.is_subrequest then
    table.insert(ngx.ctx.buffered, chunk)
    -- 将当前响应赋值为空,以修改后的内容作为最终响应
    ngx.arg[1] = nil
end

-- 如果为最后一次响应,对所有响应数据进行处理
if eof then
    -- 获取所有响应数据
    local whole = table.concat(ngx.ctx.buffered)
    ngx.ctx.buffered = nil
    
    -- 内容有指定IP
    if whole
        -- 判断响应Host是否为客户端访问Host
        and not string.match(whole, ngx.var.http_host)
    then
        -- ngx.log(ngx.ERR, "body_filter_by_lua::::响应内容:》》》\n", whole, "\n《《《")
        -- 替换外网IP,需在server或location中设置以下两个变量
        -- set $outerIP "100%.100%.100%.100"; # 外网IP
        -- set $insideIP  "172%.16%.0%.91"; # 内网IP
        whole = string.gsub(whole, ngx.var.insideIP, ngx.var.outerIP)
        -- 重新赋值响应数据,以修改后的内容作为最终响应
    end
    ngx.arg[1] = whole
end

# 方案一

  • 使用Nginx反向代理

缺点很明显:登录CAS的URL中的service参数不能替换,而且无法做判断,可自定义程度不高

location /test {
    proxy_headers_hash_max_size 51200;
    proxy_headers_hash_bucket_size 6400;
    proxy_connect_timeout 500s;
    proxy_read_timeout 500s;
    proxy_send_timeout 500s;
    proxy_pass http://server/test;
    
    proxy_set_header Host $host:$server_port;
    #proxy_set_header Host $http_host;
    #proxy_set_header Host $server_addr:$server_port;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    # 修改响应头中的"Location"和"Refresh"字段,只能替换host部分,参数部分无法替换,非常重要
    # https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_redirect
    #proxy_redirect $scheme://$server_addr:$server_port/ /;
    #proxy_redirect $scheme://$server_addr:$server_port/ $scheme://$http_host/;
    #proxy_redirect ~^http://172.16.0.1:81(.*) http://100.100.100.100:81$1; 
    proxy_redirect ~^http://172.16.0.91:81(.*) $scheme://$http_host$1;
    #proxy_set_header REMOTE-HOST $server_addr;
    proxy_set_header X-FORWARDED-HOST $server_addr;
    proxy_set_header X-FORWARDED-PORT $server_port;
    proxy_set_header Referer $http_referer;
    proxy_set_header Cookie $http_cookie;
    # response中set-cookie的domain转换
    #proxy_cookie_domain $server_addr $host; 
}

# 方案二

使用纯Nginx+Lua实现 lua-nginx-module时序图 (opens new window),点击链接后向上滑动

local uri_args = ngx.req.get_uri_args()
local outerIP = "100%.100%.100%.100"
local insideIP = "172%.16%.0%.91"
if uri_args["service"] then
    uri_args["service"] = string.gsub(uri_args["service"], outerIP, insideIP)
    ngx.req.set_uri_args(uri_args)
end

此方式可以使用Nginx全局变量实现,但可自定义程度范围不大

if ($is_args = "?"){
  
}
if ($arg_service){
    set $arg_service "http://172.16.0.91:81/test/login";
}
local resp_headers = ngx.resp.get_headers() -- 响应头
local resp_location = resp_headers.location -- 响应地址
local outerIP = "100%.100%.100%.100"
local insideIP = "172%.16%.0%.91"
if resp_location  ~= nil
    -- 判断响应Host是否为客户端访问Host
    and not string.match(ngx.header.location, ngx.var.http_host)
then
	ngx.header['location'] = string.gsub(resp_location, insideIP, outerIP)
end

其实此方式也可以使用Nginx第三方模块实现:headers-more-nginx-module (opens new window)

完整脚本

-- access_by_lua_file:
local cjson = require("cjson");

local req_headers = ngx.req.get_headers() -- 请求头
local resp_headers = ngx.resp.get_headers() -- 响应头

local uri_args = ngx.req.get_uri_args()

-- ngx.log(ngx.ERR, "header_filter_by_lua::::req_headers请求头:》》》\n", cjson.encode(req_headers), "\n《《《")
-- ngx.log(ngx.ERR, "header_filter_by_lua::::出参resp_headers响应头:》》》\n", cjson.encode(resp_headers), "\n《《《")

-- 替换请求参数
if uri_args["service"] then
    -- 替换外网IP,需在server或location中设置以下两个变量
    -- set $outerIP "100%.100%.100%.100"; # 外网IP
    -- set $insideIP  "172%.16%.0%.91"; # 内网IP
    uri_args["service"] = string.gsub(uri_args["service"], ngx.var.outerIP, ngx.var.insideIP)
    ngx.req.set_uri_args(uri_args)
end

if string.match(req_headers.host, ngx.var.outerIP) then
    -- ngx.req.set_header("Host", string.gsub(req_headers.host, ngx.var.outerIP, ngx.var.insideIP))
    -- ngx.req.set_header("X-Real-IP", "172.16.0.91")
    -- ngx.var.remote_addr = "172.16.0.91"
end
-- header_filter_by_lua_file:
local cjson = require("cjson");

local req_headers = ngx.req.get_headers() -- 请求头
local resp_headers = ngx.resp.get_headers() -- 响应头
ngx.header.content_length = nil -- body_filter_by_lua*替换内容后需要置空内容长度

-- ngx.log(ngx.ERR, "header_filter_by_lua::::req_headers请求头:》》》\n", cjson.encode(req_headers), "\n《《《")
-- ngx.log(ngx.ERR, "header_filter_by_lua::::出参resp_headers响应头:》》》\n", cjson.encode(resp_headers), "\n《《《")

-- 替换返回响应头
if ngx.header.location ~= nil
    -- 判断响应Host是否为客户端访问Host
    and not string.match(ngx.header.location, ngx.var.http_host)
then
    -- 替换响应头中的外网IP,需在server或location中设置以下两个变量
    -- set $outerIP "100%.100%.100%.100"; # 外网IP
    -- set $insideIP  "172%.16%.0%.91"; # 内网IP
    ngx.header['location'] = string.gsub(resp_headers.location, ngx.var.insideIP, ngx.var.outerIP)
end

if resp_headers.refresh then
    ngx.header['refresh'] = string.gsub(resp_headers.refresh, ngx.var.insideIP, ngx.var.outerIP)
end

其他人的一些实现

# 方案三

继承FormAuthenticationFilter动态改变各个url

  • 后端继承FormAuthenticationFilter并修改CasRealm.setCasService()为动态URL(应与访问CAS登录URL携带的service参数一致,授权是根据此参数发票)

CAS验证前端传的ticket所属域(Host)与CasService是否一致,不一致将报错:org.jasig.cas.client.validation.TicketValidationException: ticket 'ST-5490-w49WPFydIwcL9bdlY7cq-cas01.example.org' does not match supplied service. The original service was 'http://x.x.x.x:8080/test/login' and the supplied service was 'http://172.16.0.91:2931/test/login'

且在浏览器客户端不停地重定向首页->cas登录->登录带ticket死循环,查看IP下的Cookie发现ticket其实是在另一个IP下面

出现此错误的原因是:由于CasRealm.setCasService()的值是固定的(这里我并没有修改),然后在lua脚本中替换了CAS登录URL中所有的Host(错误的:http://100.100.100.100:81/cas/login?service=http://100.100.100.100:81/test/login,包含service参数部分被替换,正确的应该是:http://100.100.100.100:81/cas/login?service=http://172.16.0.91:81/test/login),这是因为在登录之后,CAS中校验授权时会发现票根的URL(http://172.16.0.91:81/test/login)与当前访问的应用URL(http://100.100.100.100:81/test/login)不一致

package com.bajins.common;

import java.io.IOException;

import javax.servlet.ServletContext;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;

import org.apache.shiro.cas.CasFilter;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.filter.authc.FormAuthenticationFilter;
import org.apache.shiro.web.filter.authc.LogoutFilter;
import org.apache.shiro.web.util.WebUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.util.StringUtils;
import org.springframework.web.context.support.WebApplicationContextUtils;

import com.bajins.common.shiro.cas.CasUserRealm;

/**
 * @Title: ImsAuthenticationFilter.java
 * @Package com.bajins.common
 * @Description: shiro动态改变loginUrl
 * @author: https://www.bajins.com
 * @date: 2021年4月15日 下午3:07:18
 * @version V1.0
 * @Copyright: 2021 bajins.com Inc. All rights reserved.
 */
public class ImsAuthenticationFilter extends FormAuthenticationFilter {

    private static transient final Logger log = LoggerFactory.getLogger(ImsAuthenticationFilter.class);

    private static final String FLAG = "/login?service=";

    private String clientUrl;
    private String serverUrl;

    /**
     * @return the clientUrl
     */
    public String getClientUrl() {
        return clientUrl;
    }

    /**
     * @param clientUrl the clientUrl to set
     */
    public void setClientUrl(String clientUrl) {
        this.clientUrl = clientUrl;
    }

    /**
     * @return the serverUrl
     */
    public String getServerUrl() {
        return serverUrl;
    }

    /**
     * @param serverUrl the serverUrl to set
     */
    public void setServerUrl(String serverUrl) {
        this.serverUrl = serverUrl;
    }

    @Override
    protected void redirectToLogin(ServletRequest request, ServletResponse response) throws IOException {

        HttpServletRequest httpServletRequest = (HttpServletRequest) request;

        String contextPath = httpServletRequest.getContextPath();

        // url - uri = domain
        int len = httpServletRequest.getRequestURL().length() - httpServletRequest.getRequestURI().length();
        String domain = httpServletRequest.getRequestURL().substring(0, len);

        /*String reg = "^(192\\.168|172\\.(1[6-9]|2\\d|3[0,1]))(\\.(2[0-4]\\d|25[0-5]|[0,1]?\\d?\\d)){2}$"
                + "|^10(\\.([2][0-4]\\d|25[0-5]|[0,1]?\\d?\\d)){3}$";
        //String reg = "(10|172|192|127)\\.([0-1][0-9]{0,2}|[2][0-5]{0,2}|[3-9][0-9]{0,1})\\.([0-1][0-9]{0,2}"
        //        + "|[2][0-5]{0,2}|[3-9][0-9]{0,1})\\.([0-1][0-9]{0,2}|[2][0-5]{0,2}|[3-9][0-9]{0,1})";
        Pattern p = Pattern.compile(reg);
        Matcher matcher = p.matcher(ipAddress);
        boolean isIntranet = matcher.find();
        if (isIntranet || httpServletRequest.getRemoteHost().equals("172.16.0.91")) { // 如果是内网
            WebUtils.issueRedirect(request, response, domain + "/cas" + loginUrl);
        } else {
            
        }*/

        // 获取servletContext容器
        ServletContext sc = httpServletRequest.getSession().getServletContext();
        // 获取web环境下spring容器
        ApplicationContext ac = WebApplicationContextUtils.getWebApplicationContext(sc);

        CasUserRealm casUserRealm = (CasUserRealm) ac.getBean("casUserRealm");
        CasFilter casFilter = (CasFilter) ac.getBean("casFilter");
        LogoutFilter logoutFilter = (LogoutFilter) ac.getBean("logoutFilter");

        ShiroFilterFactoryBean shiroFilter = (ShiroFilterFactoryBean) ac.getBean("&shiroFilter");

        // 根据客户端url中的host动态替换url
        String client = domain + contextPath;
        String clientLoginUrl = client + "/login";
        casUserRealm.setCasServerUrlPrefix(domain + getServerUrl());
        casUserRealm.setCasService(clientLoginUrl);
        casFilter.setFailureUrl(client + "/index");
        casFilter.setSuccessUrl(client + "/");
        // casFilter.setLoginUrl(loginUrl);
        logoutFilter.setRedirectUrl(domain + getServerUrl() + FLAG.replace("login", "logout") + clientLoginUrl);
        shiroFilter.setLoginUrl(domain + getServerUrl() + FLAG + clientLoginUrl);
        log.info("login跳转地址:{}", this.getLoginUrl());

        WebUtils.issueRedirect(httpServletRequest, response, this.getLoginUrl()); // 302跳转
    }

    /*@Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        return false;
    }*/

    /*@Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        String loginUrl = this.getLoginUrl();
        Subject subject = getSubject(request, response);
        if (subject.getPrincipal() == null) {// 表示没有登录,重定向到登录页面
            saveRequest(request);
            WebUtils.issueRedirect(request, response, loginUrl);
        } else {
            if (StringUtils.hasText(loginUrl)) {
                WebUtils.issueRedirect(request, response, loginUrl);
            } else {
                WebUtils.toHttp(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
            }
        }
        return true;
    }*/

    /**
     * 获取用户真实IP地址
     * <p>
     * 当我们通过request获取客户端IP时,如果对自身服务器做了反向代理。 
     * 通过request.getRemoteAddr();可能获取到的是代理服务器的IP,而无法获取到用户请求IP
     *
     * @param request
     * @return java.lang.String
     */
    public static String getIpAddress(HttpServletRequest request) {
        // X-Real-IP:Nginx服务代理
        String ipAddresses = request.getHeader("X-Real-IP");

        if (!StringUtils.hasText(ipAddresses) || "unknown".equalsIgnoreCase(ipAddresses)) {
            // Proxy-Client-IP:Apache 服务代理
            ipAddresses = request.getHeader("Proxy-Client-IP");
        }
        if (!StringUtils.hasText(ipAddresses) || "unknown".equalsIgnoreCase(ipAddresses)) {
            // WL-Proxy-Client-IP:WebLogic 服务代理
            ipAddresses = request.getHeader("WL-Proxy-Client-IP");
        }
        if (!StringUtils.hasText(ipAddresses) || "unknown".equalsIgnoreCase(ipAddresses)) {
            // HTTP_CLIENT_IP:有些代理服务器
            ipAddresses = request.getHeader("HTTP_CLIENT_IP");
        }
        if (!StringUtils.hasText(ipAddresses) || "unknown".equalsIgnoreCase(ipAddresses)) {
            ipAddresses = request.getHeader("HTTP_X_FORWARDED_FOR");
        }
        if (!StringUtils.hasText(ipAddresses) || "unknown".equalsIgnoreCase(ipAddresses)) {
            // X-Forwarded-For:Squid 服务代理 和 Nginx服务代理
            ipAddresses = request.getHeader("X-Forwarded-For");
        }
        // 有些网络通过多层代理,那么会获取到以逗号(,)分割的多个IP,第一个才是真实IP
        int index = ipAddresses.indexOf(",");
        if (index != -1) {
            ipAddresses = ipAddresses.substring(0, index);
        }
        if (!StringUtils.hasText(ipAddresses) || "unknown".equalsIgnoreCase(ipAddresses)) {
            ipAddresses = request.getRemoteAddr();
        }
        return ipAddresses;
    }
}

修改Spring-Shiro配置xml

<bean id="imsAuthenticationFilter" class="com.bajins.common.ImsAuthenticationFilter">
	<property name="serverUrl" value="${cas.server}" />
	<property name="clientUrl" value="${cas.client}" />
</bean>
<!-- Shiro的Web过滤器 -->
<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
	<property name="securityManager" ref="securityManager" />
	<!-- 原来写死的配置 -->
	<!-- <property name="loginUrl" value="${cas.server}/login?service=${cas.client}/login" /> -->
	<property name="loginUrl" value="/login?service=${cas.client}/login" />
	<property name="unauthorizedUrl" value="/unauthorized" />
	<property name="filters">
		<util:map>
			<!-- 这里把自定义的过滤器加入 -->
			<entry key="authc" value-ref="imsAuthenticationFilter" />
			<entry key="authl" value-ref="loginControlFilter" />
			<entry key="cas" value-ref="casFilter" />
			<entry key="logout" value-ref="logoutFilter" />
			<entry key="casLogout" value-ref="casLogoutFilter" />
		</util:map>
	</property>
	<!-- 指定访问地址经过指定Filter过滤 -->
	<property name="filterChainDefinitions">
		<value>
			/common/** = anon
			/css/** = anon
			/js/** = anon
			/fileUpload/**=anon
			/api/** = anon
			/changeLocale=anon
			<!-- 注意:这里不能用自定义的过滤器,否则死循环重定向 -->
			/login = authl,casLogout,cas
			/logout = logout
			<!-- 使用自定义的过滤器 -->
			/** = authc,casLogout,user
		</value>
	</property>
</bean>

参考: