CAS OAuth2 源码分析

在工程中引入以下依赖,方便看代码:

                <!-- 开启oauth支持 -->
                <dependency>
                    <groupId>org.apereo.cas</groupId>
                    <artifactId>cas-server-support-oauth-webflow</artifactId>
                    <version>${cas.version}</version>
                </dependency>
                <dependency>
                    <groupId>org.apereo.cas</groupId>
                    <artifactId>cas-server-support-actions</artifactId>
                    <version>${cas.version}</version>
                </dependency>
                <dependency>
                    <groupId>org.jasig.cas.client</groupId>
                    <artifactId>cas-client-core</artifactId>
                    <version>3.4.1</version>
                </dependency>

CAS 对Oauth2的支持主要分5个包,最主要的是 cas-server-support-oauth

包名 功能
cas-server-support-oauth-api 4种token的接口
cas-server-support-oauth-core 实现了cas-server-support-oauth-api定义的4个接口
cas-server-support-oauth 关键工程,主要功能都在这里实现
cas-server-support-oauth-services 处理配置的service.JSON文件,定义不同的客户端用的
cas-server-support-oauth-webflow 流程处理

以下一个一个分析:

  • cas-server-support-oauth-api

定义了4个接口:

  1. OAuthToken 只声明了 一个获取认证信息的方法Authentication getAuthentication()
  2. AccessToken 继承OAuthToken 增加了个 权限范围的方法 Collection<String> getScopes(),该token有一个比较短的有效时间
  3. OAuthCode 只定义了一个变量,该code只能用一次,并且只有很短的一段时间有效
  4. RefreshToken 刷新token,当AccessToken 快过期了,用RefreshToken来获取一个新的AccessToken

  • cas-server-support-oauth-core

只有5个类:3个实现类实现了上面 cas-server-support-oauth-api 的3个接口 + 1个配置类 + 1个 OAuth20Constants

  1. OAuthProtocolTicketCatalogConfiguration 创建并配置上面3个接口的实现类
  2. OAuth20Constants 包含了一些OAuth变量,如:redirect_uri,response_type,grant_type
  3. OAuthCodeImpl
  4. AccessTokenImpl
  5. RefreshTokenImpl
    上面这几个类只是3个实体类,从类上注释来看是可以持久化到数据库的.没什么内容,类图如下:
    OAuthCodeImpl.png
  • cas-server-support-oauth

CAS实现Oauth2主要是在这里,该包中分几块,

  • 先分析怎么生成ticket,主要有下面这几个类

image.png

套路都是一样的,給三种token定义创建工厂.如:RefreshTokenFactory,有一个默认实现类DefaultRefreshTokenFactory ,主要是根据UniqueTicketIdGeneratorExpirationPolicy来生成token
ExpirationPolicy 的一个实现 OAuthRefreshTokenExpirationPolicyCasOAuthConfiguration 定义的时候会用到.

    private ExpirationPolicy refreshTokenExpirationPolicy() {
        return new OAuthRefreshTokenExpirationPolicy(casProperties.getAuthn().getOauth().getRefreshToken().getTimeToKillInSeconds());
    }

OAuthRefreshTokenExpirationPolicy 继承自 AbstractCasExpirationPolicy ,比其他的Policy 多了一个属性而已timeToKillInSeconds,从上面的配置从可以看到是为了方面配置文件配置的.

同理: OAuthCodeFactory 的默认token工厂实现DefaultOAuthCodeFactory,里面也用到了定制的过期策略OAuthCodeExpirationPolicy
获取配置的代码如下:

    private ExpirationPolicy oAuthCodeExpirationPolicy() {
        final OAuthProperties oauth = casProperties.getAuthn().getOauth();
        return new OAuthCodeExpirationPolicy(oauth.getCode().getNumberOfUses(), oauth.getCode().getTimeToKillInSeconds());
    }

CasOAuthConfiguration
同理: AccessTokenFactory 默认token工厂实现DefaultAccessTokenFactory和定制的过期策略OAuthAccessTokenExpirationPolicy

  • 3个过期策略的实现对比
类名 覆写的方法 构造器传递的参数 说明
OAuthRefreshTokenExpirationPolicy isExpired timeToKillInSeconds 添加了timeToKillInSeconds属性,获取系统的时间都是ZonedDateTime.now(ZoneOffset.UTC),注意时区
OAuthCodeExpirationPolicy numberOfUses timeToKillInMilliSeconds 和其他两个不一样,这个是继承自MultiTimeUseOrTimeoutExpirationPolicy,这个不仅受时间限制,而且也受使用次数限制
OAuthAccessTokenExpirationPolicy isExpired maxTimeToLiveInSeconds timeToKillInSeconds 这个受最长时间maxTimeToLiveInSeconds 限制,和创建时间对比;timeToKillInSeconds和上次使用时间对比

从代码中可以看到,isExpired方法的参数 TicketState是包含了Ticket的重要信息在里面,是过期策略的核心数据载体.而 上面的 OAuthCodeImpl等3个实体类就实现了TicketState接口. TicketState 有个update方法,实现在 AbstractTicket 中,代码如下:

    @Override
    public void update() {
        this.previousLastTimeUsed = this.lastTimeUsed;
        this.lastTimeUsed = ZonedDateTime.now(ZoneOffset.UTC);
        this.countOfUses++;
      //每次更新完token值都会更新 TicketGrantingTicket 值的相应信息
        if (getGrantingTicket() != null && !getGrantingTicket().isExpired()) {
            final TicketState state = TicketState.class.cast(getGrantingTicket());
            state.update();
        }
    }

从代码可以看到,每次更新完token值都会更新 TicketGrantingTicket 值的相应信息


  • 分析下4个Controller,如下图所示
image.png
类名 对应的端口 备注
OAuth20AccessTokenEndpointController /oauth2.0/accessToken 和 /oauth2.0/token 获取token
OAuth20AuthorizeEndpointController /oauth2.0/authorize 认证用户信息
OAuth20CallbackAuthorizeEndpointController /oauth2.0/callbackAuthorize
OAuth20UserProfileControllerController /oauth2.0/profile 返回用户信息,JSON格式

详细信息可见官网资料
4个Controller都是继承自BaseOAuth20Controller,入口很显然都是handleRequest

  • OAuth20AccessTokenEndpointController 分析
    @PostMapping(path = {OAuth20Constants.BASE_OAUTH20_URL + '/' + OAuth20Constants.ACCESS_TOKEN_URL,
            OAuth20Constants.BASE_OAUTH20_URL + '/' + OAuth20Constants.TOKEN_URL})
    public void handleRequest(final HttpServletRequest request, final HttpServletResponse response) throws Exception {
        try {
            response.setContentType(MediaType.TEXT_PLAIN_VALUE);
            //验证请求是否合法,各种认证方式时需要的参数是否存在,是否是配置的registerService
            if (!verifyAccessTokenRequest(request, response)) {
                LOGGER.error("Access token request verification failed");
                OAuth20Utils.writeTextError(response, OAuth20Constants.INVALID_REQUEST);
                return;
            }

            final AccessTokenRequestDataHolder responseHolder;
            try {
                //检查并且转换成  AccessTokenRequestDataHolder,系统中实现了4中Extractor,在初始化的时候初始化进来
                //该方法找到第一个支持的(调用其supports方法),一般也只有一个支持的
                //找到后调用对应的extract方法 转换成需要的 AccessTokenRequestDataHolder
                responseHolder = examineAndExtractAccessTokenGrantRequest(request, response);
                LOGGER.debug("Creating access token for [{}]", responseHolder);
            } catch (final Exception e) {
                LOGGER.error("Could not identify and extract access token request", e);
                OAuth20Utils.writeTextError(response, OAuth20Constants.INVALID_GRANT);
                return;
            }

            final J2EContext context = Pac4jUtils.getPac4jJ2EContext(request, response);
            //调用对应token的生成器 OAuth20DefaultTokenGenerator 生成token ,这个生成器会同时一对token,AccessToken 和RefreshToken
            final Pair<AccessToken, RefreshToken> accessToken = accessTokenGenerator.generate(responseHolder);
            LOGGER.debug("Access token generated is: [{}]. Refresh token generated is [{}]", accessToken.getKey(), accessToken.getValue());
            //用 OAuth20AccessTokenResponseGenerator 生成需要返回的格式数据,如果配置了jsonFormat 就会生成JSON格式,否则就是默认的text
            //这里直接将response传入了,没有返回值,直接做最后的响应
            generateAccessTokenResponse(request, response, responseHolder, context, accessToken.getKey(), accessToken.getValue());
            response.setStatus(HttpServletResponse.SC_OK);
        } catch (final Exception e) {
            LOGGER.error(e.getMessage(), e);
            throw new RuntimeException(e.getMessage(), e);
        }
    }

从上面可以看出,除了常规的校验和最后生成token外,比较有意思的就是那个Extractor了,看CAS是怎么实现将requestresponse 转换成(extract方法) AccessTokenRequestDataHolder
CAS中同样配套了4个Extractor,分别对应了我们4种场景.类图如下:

BaseAccessTokenGrantRequestExtractor.png

  • BaseAccessTokenGrantRequestExtractor: 这个抽象类定义了3个抽象方法,分别是:

    1. 获取是哪一种授权模式getGrantType方法;
    2. 是否支持getGrantType的授权模式的supports方法
    3. 真正干活的转换方法extract
  • AccessTokenAuthorizationCodeGrantRequestExtractor :
    关键代码:getOAuthTokenFromRequest 根据URL中的code参数值到ticketRegistry 中获取OAuthToken
    然后就直接new一个 对象了return new AccessTokenRequestDataHolder(token, registeredService, getGrantType(), isAllowedToGenerateRefreshToken(), scopes);

  • AccessTokenRefreshTokenGrantRequestExtractor
    这个类继承自 AccessTokenAuthorizationCodeGrantRequestExtractor,主要逻辑和上面这个一样,只是一些参数返回值不同. isAllowedToGenerateRefreshToken=false

  • AccessTokenPasswordGrantRequestExtractor
    同样,主要逻辑是在extract方法中,其他都是返回特定参数而已.

    1. AccessTokenAuthorizationCodeGrantRequestExtractor 一样,首先都是根据URL中的clientId得到配置的registeredService
    2. 比较特别的是会根据request, response得到 J2EContext ,这个是在pac4j中定义的,最终会得到 UserProfile ,这个profile在后面获取TGT时候有用
      怎么获取TGT的呢?关键是下面这几行代码
        // 根据 OAuth20CasAuthenticationBuilder 构造出  service,
        final Service service = this.authenticationBuilder.buildService(registeredService, context, requireServiceHeader);

        LOGGER.debug("Authenticating the OAuth request indicated by [{}]", service);
        // 生成 Authentication
        final Authentication authentication = this.authenticationBuilder.build(uProfile, registeredService, context, service);
        //确保 registeredService 合法
        RegisteredServiceAccessStrategyUtils.ensurePrincipalAccessIsAllowedForService(service, registeredService, authentication);      
        final AuthenticationResult result = new DefaultAuthenticationResult(authentication, requireServiceHeader ? service : null);
        //关键代码:生成TGT centralAuthenticationService 在 CasOAuthConfiguration 注入的
        final TicketGrantingTicket ticketGrantingTicket = this.centralAuthenticationService.createTicketGrantingTicket(result);

TicketGrantingTicket 在CAS中是保存时间最长的ticket,后续的token都可以根据这个来生成,有这个ticket就相当于在CAS中创建了会话.

  • AccessTokenClientCredentialsGrantRequestExtractor
    这个类继承自AccessTokenPasswordGrantRequestExtractor,没啥特别代码. 略...

  • OAuth20AuthorizeEndpointController

返回一个code或者accessToken

    @GetMapping(path = OAuth20Constants.BASE_OAUTH20_URL + '/' + OAuth20Constants.AUTHORIZE_URL)
    public ModelAndView handleRequest(final HttpServletRequest request, final HttpServletResponse response) throws Exception {
        final J2EContext context = Pac4jUtils.getPac4jJ2EContext(request, response);
        final ProfileManager manager = Pac4jUtils.getPac4jProfileManager(request, response);
        //验证请求是否合法,定义了接口 OAuth20RequestValidator 有很多实现类在 org.apereo.cas.support.oauth.validator 包下
        //和上面的套路一样接口定义了一个support方法,找到一个支持的就用它了,然后调用 validate 方法验证即可     
        if (!verifyAuthorizeRequest(context) || !isRequestAuthenticated(manager, context)) {
            LOGGER.error("Authorize request verification failed. Either the authorization request is missing required parameters, "
                    + "or the request is not authenticated and contains no authenticated profile/principal.");
            return OAuth20Utils.produceUnauthorizedErrorView();
        }

        final String clientId = context.getRequestParameter(OAuth20Constants.CLIENT_ID);
        final OAuthRegisteredService registeredService = getRegisteredServiceByClientId(clientId);
        try {
            //验证是否有该service的权限
            RegisteredServiceAccessStrategyUtils.ensureServiceAccessIsAllowed(clientId, registeredService);
        } catch (final Exception e) {
            LOGGER.error(e.getMessage(), e);
            return OAuth20Utils.produceUnauthorizedErrorView();
        }
        // 返回授权界面,confirm.html
        final ModelAndView mv = this.consentApprovalViewResolver.resolve(context, registeredService);
        if (!mv.isEmpty() && mv.hasView()) {
            return mv;
        }
        // 调用 OAuth20AuthorizationResponseBuilder 接口返回URL
        return redirectToCallbackRedirectUrl(manager, registeredService, context, clientId);
    }
OAuth20RequestValidator.png

  • OAuth20CallbackAuthorizeEndpointController

这个代码很少.主要是回调了一下,

final DefaultCallbackLogic callback = new DefaultCallbackLogic();
        callback.perform(context, oauthConfig, J2ENopHttpActionAdapter.INSTANCE, null, false, false);

  • OAuth20UserProfileControllerController
    @GetMapping(path = OAuth20Constants.BASE_OAUTH20_URL + '/' + OAuth20Constants.PROFILE_URL, produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<String> handleRequest(final HttpServletRequest request, final HttpServletResponse response) throws Exception {
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        //从请求参数或者header中获取token值
        final String accessToken = getAccessTokenFromRequest(request);
        if (StringUtils.isBlank(accessToken)) {
            LOGGER.error("Missing [{}]", OAuth20Constants.ACCESS_TOKEN);
            return buildUnauthorizedResponseEntity(OAuth20Constants.MISSING_ACCESS_TOKEN);
        }
        //转换成内部的token,验证其是否过期
        final AccessToken accessTokenTicket = this.ticketRegistry.getTicket(accessToken, AccessToken.class);
        if (accessTokenTicket == null || accessTokenTicket.isExpired()) {
            LOGGER.error("Expired/Missing access token: [{}]", accessToken);
            return buildUnauthorizedResponseEntity(OAuth20Constants.EXPIRED_ACCESS_TOKEN);
        }
        //验证对应的TGT是否有效
        final TicketGrantingTicket ticketGrantingTicket = accessTokenTicket.getGrantingTicket();
        if (ticketGrantingTicket == null || ticketGrantingTicket.isExpired()) {
            LOGGER.error("Ticket granting ticket [{}] parenting access token [{}] has expired or is not found", ticketGrantingTicket, accessTokenTicket);
            this.ticketRegistry.deleteTicket(accessToken);
            return buildUnauthorizedResponseEntity(OAuth20Constants.EXPIRED_ACCESS_TOKEN);
        }
        //更新token的状态,修改最后使用时间等
        updateAccessTokenUsage(accessTokenTicket);
        //根据token得到认证信息,返回map
        final Map<String, Object> map = writeOutProfileResponse(accessTokenTicket);
        //如果是Oauth2的就添加另外两个值,client_id和service
        finalizeProfileResponse(accessTokenTicket, map);
        //处理返回的用户信息,JSON格式,OAuth20DefaultUserProfileViewRenderer 处理了FLAT 和 NESTED 模式
        final String value = this.userProfileViewRenderer.render(map, accessTokenTicket);
        return new ResponseEntity<>(value, HttpStatus.OK);
    }

  • cas-server-support-oauth-services

    只有一个类 OAuthRegisteredService,用于处理配置不同的客户端services.json文件,管理其密钥和ID等相关信息,继承自RegexRegisteredService

  • cas-server-support-oauth-webflow

    3个类:
  1. CasOAuthWebflowConfiguration 配置两个bean oauth20LogoutWebflowConfigureroauth20RegisteredServiceUIAction
  2. OAuth20RegisteredServiceUIAction .
  3. OAuth20WebflowConfigureroauth20RegisteredServiceUIAction加入到登录流程当中

tips :
CasWebflowConstants.STATE_ID_VIEW_LOGIN_FORM ->String STATE_ID_VIEW_LOGIN_FORM = "viewLoginForm"; 流程stateviewLoginForm 对应界面casLoginView.html:<view-state id="viewLoginForm" view="casLoginView"


  • 杂项:

利用org.apache.commons.lang3.builder 包中的方法覆写下面OAuthRegisteredService的这三个方法
@Entity 都要覆写这三个方法.

  • 覆写 toString方法
    @Override
    public String toString() {
        final ToStringBuilder builder = new ToStringBuilder(this);
        builder.appendSuper(super.toString());
        builder.append("clientId", getClientId());
        builder.append("approvalPrompt", isBypassApprovalPrompt());
        builder.append("generateRefreshToken", isGenerateRefreshToken());
        builder.append("jsonFormat", isJsonFormat());
        builder.append("supportedResponseTypes", getSupportedResponseTypes());
        builder.append("supportedGrantTypes", getSupportedGrantTypes());

        return builder.toString();
    }
    /**
     * Build a normalized "toString" text for an object.
     *CommonHelper.toString(this.getClass(), "size", size, "timeout", timeout, "timeUnit", timeUnit)
     * @param clazz class
     * @param args  arguments
     * @return a normalized "toString" text
     */
    public static String toString(final Class<?> clazz, final Object... args) {
        final StringBuilder sb = new StringBuilder();
        sb.append("#");
        sb.append(clazz.getSimpleName());
        sb.append("# |");
        boolean b = true;
        for (final Object arg : args) {
            if (b) {
                sb.append(" ");
                sb.append(arg);
                sb.append(":");
            } else {
                sb.append(" ");
                sb.append(arg);
                sb.append(" |");
            }
            b = !b;
        }
        return sb.toString();
    }

  • 覆写 equals方法

    @Override
    public boolean equals(final Object obj) {
        if (obj == null) {
            return false;
        }
        if (obj == this) {
            return true;
        }
        if (obj.getClass() != getClass()) {
            return false;
        }
        final OAuthRegisteredService rhs = (OAuthRegisteredService) obj;
        final EqualsBuilder builder = new EqualsBuilder()
                .appendSuper(super.equals(obj))
                .append(this.clientSecret, rhs.clientSecret)
                .append(this.clientId, rhs.clientId)
                .append(this.bypassApprovalPrompt, rhs.bypassApprovalPrompt)
                .append(this.generateRefreshToken, rhs.generateRefreshToken)
                .append(this.jsonFormat, rhs.jsonFormat)
                .append(this.supportedResponseTypes, rhs.supportedResponseTypes)
                .append(this.supportedGrantTypes, rhs.supportedGrantTypes);
        
        return builder.isEquals();
    }
  • 覆写 hashCode 方法
    @Override
    public int hashCode() {
        return new HashCodeBuilder()
                .appendSuper(super.hashCode())
                .append(this.clientSecret)
                .append(this.clientId)
                .append(this.bypassApprovalPrompt)
                .append(this.generateRefreshToken)
                .append(this.jsonFormat)
                .append(this.supportedResponseTypes)
                .append(this.supportedGrantTypes)
                .toHashCode();
    }
    @Override
    public int hashCode() {
        return new HashCodeBuilder(13, 133).append(this.getId()).toHashCode();
    }
  • 覆写 compareTo方法
    @Override
    public int compareTo(final RegisteredService other) {
        return new CompareToBuilder()
                .append(getEvaluationOrder(), other.getEvaluationOrder())
                .append(StringUtils.defaultIfBlank(getName(), StringUtils.EMPTY).toLowerCase(),
                        StringUtils.defaultIfBlank(other.getName(), StringUtils.EMPTY).toLowerCase())
                .append(getServiceId(), other.getServiceId())
                .append(getId(), other.getId())
                .toComparison();
    }
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,905评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,140评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,791评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,483评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,476评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,516评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,905评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,560评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,778评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,557评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,635评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,338评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,925评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,898评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,142评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,818评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,347评论 2 342

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,580评论 18 139
  • 文章图片上传不正常,如需文档,可联系微信:1017429387 目录 1 安装... 4 1.1 配置探针... ...
    Mrhappy_a7eb阅读 6,276评论 0 5
  • iOS网络架构讨论梳理整理中。。。 其实如果没有APIManager这一层是没法使用delegate的,毕竟多个单...
    yhtang阅读 5,160评论 1 23
  • 一.前期准备 1.cas源码版本 2.服务端 3.cas client 4.cas client web.xml配...
    Eric_暗夜阅读 4,346评论 0 1
  • 文/星夜行 1 深夜,清冷的月光透过层层雾气撒落下来,让人骨子里都能够感觉到这无孔不入的寒意。火把的火焰也在有气无...
    星夜行阅读 661评论 1 10