.NET集成CAS认证艰难历程

发布时间 2023-10-30 17:23:02作者: 念冬的叶子

1.前言

  本文不再赘述单点登录SSO原理,主要针对CAS认证服务方式集成.NET应用,从实施落地过程回顾期间遇到的坑和解决方案做些心得总结,希望对你有帮忙,如有问题,请留言一起探讨学习

2.核心客户端组件

  DotNetCasClient.dll,本项目依赖.NET4.5版本,官方提供用于集成CAS客户端源码地址 GitHub - apereo/dotnet-cas-client: Apereo .NET CAS Client,编译和调试工具需基于vs2017及以上

3.项目回顾

  CAS认证集成是公司首次对接项目,结合客户提供的CAS认证协议以及官网相关资料,前期的设计方案很粗略,这为后续的项目落地带来不少麻烦,在此告诫各位爱好编程的朋友们,项目前期设计方案很重要。

  整个集成主要包含三个主要环节,CAS认证模拟服务环境如何搭建,应用程序如何集成CAS认证,测试联调以及完善,下面也主要从这三个方面来概述

3.1.CAS认证模拟服务环境搭建

  为了在开发环境联调,同步模拟一套CAS认证服务,基于windows server2008R2以上/win10+,中间件Java1.8,Tomcat8,安装配置并系统环境变量即可,不做描述了。

  CAS在GitHub源代码地址:https://github.com/apereo/cas/releases?q=v4.2.7&expanded=true,当然可以选择Maven官网release已发布版本https://mvnrepository.com/artifact/org.jasig.cas/cas-server-webapp,目前支持最新的发布版本4.2.7,要求版本必须高于需要集成客户CAS服务的版本,选择对应版本FIles详情war下载到本地,如下图所示位置:

  将war解压到本地,将解压包拷贝到Tomcat安装目录webapps下重命名cas(站点跟目录),注意版本v4.2.7该release发布的默认是以https访问,需要本地安装证书支持,网上有相关资料。若想默认支持http方式认证,需要做如下修改:

  1)\WEB-INF\cas.properties修改tgc.secure=false,warn.cookie.secure=false

  2)\WEB-INF\classes\services\修改增加支持http,"serviceId" : "^(https|imaps|http)://.*",

  3)\WEB-INF\view\jsp\default\ui\casLoginView.jsp 注释如下代码:

<!--<c:if test="${not pageContext.request.secure}">
    <div id="msg" class="errors">
        <h2><spring:message code="screen.nonsecure.title" /></h2>
        <p><spring:message code="screen.nonsecure.message" /></p>
    </div>
</c:if> -->

  启动Tomcat,web浏览器http://localhost:8080/cas/login,如下所示:

  默认用户密码是在\WEB-INF\cas.properties下accept.authn.users配置项的值,输入即可完成登录

3.2.CAS客户端集成

   DotNetCasClient源码下载到本地,启动VS编译,源码下有三个项目

  其中1为核心组件项目,启动VS项目编译成功之后将bin文件引入到引用的程序包当中,以MVC案例项目说明,需要进行如下配置:

  1)web.config文件配置

  引入DotNetCasClient组件,configSections节点下增加section

<section name="casClientConfig" type="DotNetCasClient.Configuration.CasClientConfiguration, DotNetCasClient"/>

  添加cas配置信息节点,官网给出的配置中需要将serviceTicketManager,gatewayStatusCookieName去掉

<!--CAS配置说明:casServerLoginUrl配置cas登录地址;
    casServerUrlPrefix配置cas服务端访问地址;
    ServerName配置cas回调当前项目地址。其他不做修改-->
<casClientConfig
    casServerLoginUrl="http://10.60.1.9:8080/cas/login"
    casServerUrlPrefix="http://10.60.1.9:8080/cas/p3"
    serverName="http://localhost:9587"
    notAuthorizedUrl="~/NotAuthorized.aspx"
    cookiesRequiredUrl="~/CookiesRequired.aspx"
    redirectAfterValidation="true"
    gateway="false"
    renew="false"
    singleSignOut="true"
    ticketTimeTolerance="5000"
    ticketValidatorName="Cas20"
    serviceTicketManager="CacheServiceTicketManager" />

  system.web节点下增加权限认证登录方式,增加httpModules,主要IIS应用程序需要兼容32位

<!--用户登录认证配置说明:loginUrl配置CAS登录地址-->
<authentication mode="Forms">
    <forms name="CasAuthLogin"
        loginUrl="http://10.60.1.9:8080/cas/login"
        timeout="3000"
        cookieless="UseCookies"
        defaultUrl="~/Home/Index"
        slidingExpiration="true"
        path="/" />
</authentication>
<httpModules>
    <add name="DotNetCasClient" type="DotNetCasClient.CasAuthenticationModule,DotNetCasClient"/>
</httpModules>

  system.webServer下增加DotNetCasClient模块

<modules>
    <remove name="DotNetCasClient"/>
    <add name="DotNetCasClient" type="DotNetCasClient.CasAuthenticationModule,DotNetCasClient"/>
    <remove name="FormsAuthenticationModule" />
</modules>

  最后增加system.diagnostics节点,属性描述可从官网上了解,主要配置initializeData需要开启IUser,IIS_Users的控制权限,否则客户端认证无法写入日志,不方便问题排查

<system.diagnostics>
    <trace autoflush="true" useGlobalLock="false" />
    <sharedListeners>
        <add name="TraceFile"
             type="System.Diagnostics.TextWriterTraceListener"
             initializeData="D:\project\logs\DotNetCasClient.Log"
             traceOutputOptions="DateTime" />
    </sharedListeners>
    <sources>
        <source name="DotNetCasClient.Config" switchName="Config" switchType="System.Diagnostics.SourceSwitch" >
            <listeners>
                <add name="TraceFile" />
            </listeners>
        </source>
        <source name="DotNetCasClient.HttpModule" switchName="HttpModule" switchType="System.Diagnostics.SourceSwitch" >
            <listeners>
                <add name="TraceFile" />
            </listeners>
        </source>
        <source name="DotNetCasClient.Protocol" switchName="Protocol" switchType="System.Diagnostics.SourceSwitch" >
            <listeners>
                <add name="TraceFile" />
            </listeners>
        </source>
        <source name="DotNetCasClient.Security" switchName="Security" switchType="System.Diagnostics.SourceSwitch" >
            <listeners>
                <add name="TraceFile" />
            </listeners>
        </source>
    </sources>
    <switches>
        <add name="Config" value="Information"/>
        <add name="HttpModule" value="Information"/>
        <add name="Protocol" value="Verbose"/>
        <add name="Security" value="Information"/>
    </switches>
</system.diagnostics>

  客户端相关Config配置已经完成,接下来需要增加控制层用户权限过滤器CasAuthorizeAttribute继承AuthorizeAttribute

/// <summary>
/// 定义用户登录状态 
/// 0未登录 1已登录 2状态错误 3无权限
/// </summary>
int _loginStatus;
public override void OnAuthorization(AuthorizationContext filterContext)
{
      //获取用户casTicket
        var casTicket = filterContext.HttpContext.Request.GetCasAuthorizeTicket();
        if (CasAuthorizeHelper.IsTokenValid(casTicket))
        {
            Auth_Accounts account = null;
            var accountId = string.Empty;
            //根据CAS认证返回信息创建本地用户信息缓存
            if (casTicket.Assertion.Attributes.ContainsKey("userId"))
            {
                accountId = casTicket.Assertion.Attributes["userId"][0];
                account = CasUserContext.GetUserInfo(accountId);
            }
            if (account != null)
            {
                this._loginStatus = 1;
                UserContext.GetUserAccount = GetUser;
                //执行了基类的OnAuthorization才会执行AuthorizeCore
                base.OnAuthorization(filterContext);
            }
            else
            {
                //CAS用户登录,系统无该用户回到登录界面
                this._loginStatus = 2;
            }
        }
        else
        {
            this._loginStatus = 3;
        }
        base.OnAuthorization(filterContext);
}
protected override bool AuthorizeCore(HttpContextBase httpContext)
{
    LogUtil.Info("AuthorizeCore _loginStatus数值:{0}", this._loginStatus.ToString());
    if (ConstConfig.CasAuthorizedSwitch)
    {
        return this._loginStatus == 1;
    }
    else
    {
        return base.AuthorizeCore(httpContext);
    }
}
protected override void HandleUnauthorizedRequest(AuthorizationContext filterContext)
{
    if (ConstConfig.CasAuthorizedSwitch)
    {
        base.HandleUnauthorizedRequest(filterContext);
        var redirectUrl = UrlUtil.ConstructLoginRedirectUrl(false, false);
        filterContext.Result = new RedirectResult(redirectUrl, true);
    }
    else { return; }
}

  创建获取票据验证类CasAuthorizeHelper

public static class CasAuthorizeHelper
{
    public static CasAuthenticationTicket GetCasAuthorizeTicket(this HttpRequestBase request)
    { 

        CasAuthenticationTicket casTicket = null;
        var ticketCookie = request.Cookies[FormsAuthentication.FormsCookieName];if (ticketCookie != null && !string.IsNullOrWhiteSpace(ticketCookie.Value))
        {
            var ticket = FormsAuthentication.Decrypt(ticketCookie.Value);if (ticket != null && CasAuthentication.ServiceTicketManager != null)
            {
                casTicket = CasAuthentication.ServiceTicketManager.GetTicket(ticket.UserData);
                //记录登录用户Ticket消息明细信息
                LogUtil.Info("ticket:{0},获取CAS用户认证信息:{1}", ticket.UserData, SerializerUtil.ToJson(casTicket));
            }
        }
        return casTicket;

    }
    public static CasAuthenticationTicket GetCasAuthorizeTicket()
    {
        CasAuthenticationTicket casTicket = null;
        var ticketCookie = HttpContext.Current.Request.Cookies[FormsAuthentication.FormsCookieName];
        if (ticketCookie != null && !string.IsNullOrWhiteSpace(ticketCookie.Value))
        {
            var ticket = FormsAuthentication.Decrypt(ticketCookie.Value);
            if (ticket != null && CasAuthentication.ServiceTicketManager != null)
            {
                casTicket = CasAuthentication.ServiceTicketManager.GetTicket(ticket.UserData);
            }
        }
        return casTicket;
    }
    /// <summary>
    /// 验证票据是否有效
    /// </summary>
    /// <param name="ticket"></param>
    /// <returns></returns>
    public static bool IsTokenValid(CasAuthenticationTicket ticket)
    {
        if (ticket == null) return false;
        return CasAuthentication.ServiceTicketManager.VerifyClientTicket(ticket);
    }
    public static void Abandon()
    {
        CasAuthentication.ClearAuthCookie();
    }
}

  最后需要控制器Controller上增加过滤器

  

   启动站点此时默认会跳转到统一认证登录界面了。如果需要对每个请求接口都增加用户身份认证,则需要对重写ActionFilterAttribute的OnActionExecuting,来验证票据是否有效,如果失效,则需要回到统一认证登录页面,注意ajax请求时,需要重定向处理。

3.2.CAS集成数据库联调

  CAS4.2.7认证服务中心集成mysql说明,包括引入组件,数据库链接,用户返回属性设置等

  1)数据库链接配置修改:\WEB-INF\deployerConfigContext.xml,本次案例采用MD5加密

  <!-- begin 从数据库中的用户表中读取 -->
    <bean id="queryDatabaseAuthenticationHandler" name="primaryAuthenticationHandler" class="org.jasig.cas.adaptors.jdbc.QueryDatabaseAuthenticationHandler">
        <!-- 加密配置,注释之后显示为明文 -->
        <property name="passwordEncoder" ref="MD5PasswordEncoder"/>
    </bean>
    <!-- MD5加密 -->
    <bean id="MD5PasswordEncoder" class="org.jasig.cas.authentication.handler.DefaultPasswordEncoder" autowire="byName">
        <constructor-arg value="MD5"/>
    </bean>
    
    <alias name="dataSource" alias="queryDatabaseDataSource"/>
    <!-- mysql-connector-java-5.x.x jar 包对应的数据库连接驱动为"com.mysql.jdbc.Driver", mysql-connector-java-8.x.x jar 包对应的数据库连接驱动为"com.mysql.cj.jdbc.Driver" -->
    <!-- 数据库连接 127.0.0.1 为数据库地址,3306为 mysql 数据库默认端口,iportalusers 为数据库名-->
    <!-- 数据库用户名为"root",密码为"supermap" -->
    <bean id="dataSource"    
        class="com.mchange.v2.c3p0.ComboPooledDataSource"
        p:driverClass="com.mysql.jdbc.Driver"
        p:jdbcUrl="jdbc:mysql://10.60.1.189:3306/test_cas?useUnicode=true&amp;characterEncoding=UTF-8&amp;zeroDateTimeBehavior=convertToNull&amp;useSSL=false"
        p:user="root"
        p:password="HAIyi@2022"
        p:initialPoolSize="6"
        p:minPoolSize="6"
        p:maxPoolSize="18"
        p:maxIdleTimeExcessConnections="120"
        p:checkoutTimeout="10000"
        p:acquireIncrement="6"
        p:acquireRetryAttempts="5"
        p:acquireRetryDelay="2000"
        p:idleConnectionTestPeriod="30"
        p:preferredTestQuery="select 1"/>
    <!--end 从数据库中的用户表中读取 -->

  同时需要注释掉原来的acceptUsersAuthenticationHandler

<!-- <alias name="acceptUsersAuthenticationHandler" alias="primaryAuthenticationHandler" /> -->

  2)增加用户信息字段输出,这里是出现问题排查时间最长的点,请求一致无法获取需要的字段,值得注意的是CAS低版本和当前版本配置存在很大差异,目前只在4.2.7版本上完成配置验证

    <!--begin 返回属性信息 -->
    <bean id="dataSourceAttribute"
        class="org.jasig.services.persondir.support.jdbc.SingleRowJdbcPersonAttributeDao">
        <constructor-arg index="0" ref="dataSource" />
        <constructor-arg index="1" value="select * from t_user where {0}" />
        <property name="queryAttributeMapping">
            <map>
<entry key="username" value="数据库字段" /> </map> </property> <property name="resultAttributeMapping"> <map> <entry key="数据库字段" value="userId" /> </map> </property> </bean> <alias name="dataSourceAttribute" alias="attributeRepository" /> <!--begin 返回更多信息 --> <!-- <bean id="attributeRepository" class="org.jasig.services.persondir.support.NamedStubPersonAttributeDao" p:backingMap-ref="attrRepoBackingMap" /> --> <!-- <util:map id="attrRepoBackingMap"> </util:map> -->

  注意注意注释默认attributeRepository,attrRepoBackingMap必须注释,否则无法返回用户信息。

  修改\WEB-INF\ cas.properties增加数据库链接配置,如下:

cas.jdbc.authn.query.sql=select pass_word from t_user where work_no = ?

  增加认证票成功对象定义,修改\WEB-INF\view\jsp\protocol\3.0\casServiceValidationSuccess.jsp,增加如下返回节点,如果你的认证服务协议用的2.0,那么修改目录\WEB-INF\view\jsp\protocol\2.0\下

    <c:if test="${fn:length(assertion.chainedAuthentications[fn:length(assertion.chainedAuthentications)-1].principal.attributes) > 0}">   
            <cas:attributes>   
                <c:forEach var="attr" items="${assertion.chainedAuthentications[fn:length(assertion.chainedAuthentications)-1].principal.attributes}">                             
                    <cas:${fn:escapeXml(attr.key)}>${fn:escapeXml(attr.value)}</cas:${fn:escapeXml(attr.key)}>                                 
                </c:forEach>     
            </cas:attributes>   
        </c:if>

  OK,模拟搭建的CAS票据认证用户信息已返回,接下来客户端封装的组件如何抓取cas:attributess需要的属性,在此感谢lention博主的分享结合实际项目进行改造

  3)拓展DotNetCASClient源码,修改Validation\Schema\Cas20\AuthenticationSuccess认证返回信息类型

[Serializable]
[DebuggerStepThrough]
[DesignerCategory("code")]
[XmlType(Namespace = "http://www.yale.edu/tp/cas")]
public class AuthenticationSuccess
{
    internal AuthenticationSuccess() { }

    [XmlElement("user")]
    public string User
    {
        get;
        set;
    }

    [XmlElement("proxyGrantingTicket")]
    public string ProxyGrantingTicket
    {
        get;
        set;
    }

    [XmlArray("proxies")]
    [XmlArrayItem("proxy", IsNullable = false)]
    public string[] Proxies
    {
        get;
        set;
    }
    [XmlElement("attributes")]
    public Attributes Attributes
    {
        get;
        set;
    }
}
[Serializable]
[DebuggerStepThrough]
[XmlType(Namespace = "http://www.yale.edu/tp/cas")]
public class Attributes 
{
    [XmlElement("user_name")]
    public string user_name { get; set; }

    [XmlElement("userId")]
    public string userId{ get; set; }

}

  修改Validation\TicketValidator\ParseResponseFromServer,处理反序列化的对象

if (authSuccessResponse.Proxies != null && authSuccessResponse.Proxies.Length > 0)
{
    return new CasPrincipal(new Assertion(authSuccessResponse.User), proxyGrantingTicketIou, authSuccessResponse.Proxies);
}
else
{
    try
    {
        var assertion = new Assertion(authSuccessResponse.User);
        if (authSuccessResponse.Attributes != null)
        {
            var dic = new Dictionary<string, IList<string>>();
            if (!string.IsNullOrEmpty(authSuccessResponse.Attributes.user_name))
            {
                dic.Add("user_name", new List<string> { authSuccessResponse.Attributes.user_name });
            }
            if (!string.IsNullOrEmpty(authSuccessResponse.Attributes.userId))
            {
                dic.Add("userId", new List<string> { authSuccessResponse.Attributes.userId });
            }
            assertion = new Assertion(authSuccessResponse.User, dic);
        }
        return new CasPrincipal(assertion, proxyGrantingTicketIou);
    }
    catch (Exception ex)
    {
        throw new TicketValidationException(string.Format("CAS Server response parse failure:{0}", ex.Message));
    }
}

  CAS集成项目落地实施完成,中间联调出现的问题很多,1)客户协议给的验证服务地址是http://ip:port/casserver,而实际用的是http://ip:port/casserver/p3,自己排查起来花了很长时间,一致找到返回用户属性,2)返回cas:attributes模拟环境中配置了返回属性一致无法显示,切记\WEB-INF\deployerConfigContext.xml配置关于返回的用户属性attrRepoBackingMap一定要注释掉。

  集成客户或第三方系统联调过程的成本要比预期的都难,作为技术的爱好者,方法总比困难多,坚持不放弃,结合周边资源,多角度考虑问题,坚信自己有所突破,至此全篇结束。