1. 程式人生 > >CAS 5.3 整合 OAuth2.0 客戶端簡要解析

CAS 5.3 整合 OAuth2.0 客戶端簡要解析

1 前言

CAS和 oauth2.0 不做介紹,自行查詢資料。

在cas5 中,提供了整合第三方oauth登陸(如微信、qq等) 的方法,這些功能是基於 Pac4j 包實現的。

有一些文章提供了配置方法,本文將對流程做一些補充和分析。

2 流程

基本流程圖大致如下。一般會先在第三方網站得到appid app_secrete 等資訊,然後在客戶端引導使用者跳轉到第三方網站登入(圖中A 流程)。

這個網站一般長這樣:

這個是微博的,可以看到裡面有redirect_uri 引數,當用戶登入成功,會302到這裡,並且攜帶 code 引數(流程B)

接下來分析cas-server 整合時都發生了什麼。

3 CAS-server配置

讀cas官方文件連結 可知,需要引入相應依賴。cas已經做了github , google 等第三方登陸客戶端整合。而對於自定義的OAuth2整合,需要在配置檔案中使用欄位,這些欄位是陣列形式,每個下標定義了一種 oauth2 client:

Cas5 是使用springboot 構建的,配置檔案被對映為 配置類

org.apereo.cas.configuration.CasConfigurationProperties

然後在@Configuration 檔案中初始化所有的 oauth2 client 例項 核心程式碼如下:

@Configuration("pac4jAuthenticationEventExecutionPlanConfiguration")
@EnableConfigurationProperties(CasConfigurationProperties.class)
@Slf4j
public class Pac4jAuthenticationEventExecutionPlanConfiguration implements AuditTrailRecordResolutionPlanConfigurer {
    @Autowired
    private CasConfigurationProperties casProperties;

   
    @Bean
    @ConditionalOnMissingBean(name = "pac4jDelegatedClientFactory")
    @RefreshScope
    public DelegatedClientFactory pac4jDelegatedClientFactory() {
        return new DelegatedClientFactory(casProperties.getAuthn().getPac4j());
    }

    @RefreshScope
    @Bean
    public Clients builtClients() {
        final Set<BaseClient> clients = pac4jDelegatedClientFactory().build();
        LOGGER.debug("The following clients are built: [{}]", clients);
        if (clients.isEmpty()) {
            LOGGER.warn("No delegated authentication clients are defined and/or configured");
        } else {
            LOGGER.info("Located and prepared [{}] delegated authentication client(s)", clients.size());
        }
        return new Clients(casProperties.getServer().getLoginUrl(), new ArrayList<>(clients));
    }

配置了 DelegatedClientFactory ,然後使用這個Factory 生成了  Clients 例項,這個例項可以看作Client的容器,裡面放置了所有的Client 以便後期查詢和使用。

看看在Factory build 時,發生了什麼:

/**
     * Build set of clients configured.
     *
     * @return the set
     */
    public Set<BaseClient> build() {
        final Set<BaseClient> clients = new LinkedHashSet<>();

        configureCasClient(clients);
        configureFacebookClient(clients);
        configureOidcClient(clients);
        configureOAuth20Client(clients);
        configureSamlClient(clients);
        configureTwitterClient(clients);
        configureDropboxClient(clients);
        configureFoursquareClient(clients);
        configureGithubClient(clients);
        configureGoogleClient(clients);
        configureWindowsLiveClient(clients);
        configureYahooClient(clients);
        configureLinkedInClient(clients);
        configurePaypalClient(clients);
        configureWordpressClient(clients);
        configureBitbucketClient(clients);
        configureOrcidClient(clients);

        return clients;
    }

其中關於自定義auth2 的方法:

/**
     * Configure o auth 20 client.
     *
     * @param properties the properties
     */
    protected void configureOAuth20Client(final Collection<BaseClient> properties) {
        final AtomicInteger index = new AtomicInteger();
        pac4jProperties.getOauth2()
            .stream()
            .filter(oauth -> StringUtils.isNotBlank(oauth.getId()) && StringUtils.isNotBlank(oauth.getSecret()))
            .forEach(oauth -> {
                final GenericOAuth20Client client = new GenericOAuth20Client();
                client.setKey(oauth.getId());
                client.setSecret(oauth.getSecret());
                client.setProfileAttrs(oauth.getProfileAttrs());
                client.setProfileNodePath(oauth.getProfilePath());
                client.setProfileUrl(oauth.getProfileUrl());
                client.setProfileVerb(Verb.valueOf(oauth.getProfileVerb().toUpperCase()));
                client.setTokenUrl(oauth.getTokenUrl());
                client.setAuthUrl(oauth.getAuthUrl());
                client.setCustomParams(oauth.getCustomParams());
                final int count = index.intValue();
                if (StringUtils.isBlank(oauth.getClientName())) {
                    client.setName(client.getClass().getSimpleName() + count);
                }
                if (oauth.isUsePathBasedCallbackUrl()) {
                    client.setCallbackUrlResolver(new PathParameterCallbackUrlResolver());
                }
                configureClient(client, oauth);

                index.incrementAndGet();
                LOGGER.debug("Created client [{}]", client);
                properties.add(client);
            });
    }

/**
     * Sets client name.
     *
     * @param client the client
     * @param props  the props
     */
    protected void configureClient(final BaseClient client, final Pac4jBaseClientProperties props) {
        if (StringUtils.isNotBlank(props.getClientName())) {
            client.setName(props.getClientName());
        }
        client.getCustomProperties().put("autoRedirect", props.isAutoRedirect());
    }

可以看到, 就是把已實現的client (google facebook)和其他型別的都註冊為client。我們關心的Oauth2 的註冊過程也很明顯。

其中需要注意的是, client.setName ,是設定client的名稱,使用的實現類為GenericOAuth20Client 。  這個類和Clients 都是pac4j提供。 

4 OAuth2 Redirect回撥

接下來分析在流程B中發生了什麼。

在CAS中使用SpringWebFlow 控制web流程,回撥入口在DelegatedClientAuthenticationAction 中定義。

執行doExecute ,其中的邏輯為:

1先從request中得到ClientName,

2 findDelegatedClientByName   按照連結中的clientName 找到對應的Client

3 嘗試用這個 Client.getCredentials  得到憑據

4 如果獲取成功,則認證成功。

5  establishDelegatedAuthenticationSession 中將回調的Credentials 包裝為ClientCredential ,建立認證上下文。

那麼核心程式碼就在Client .getCredentials 方法中。Client 的具體實現類很多,自定義OAuth2使用的是

GenericOAuth20Client 。

@Override
    public Event doExecute(final RequestContext context) {
        final HttpServletRequest request = WebUtils.getHttpServletRequestFromExternalWebflowContext(context);
        final HttpServletResponse response = WebUtils.getHttpServletResponseFromExternalWebflowContext(context);

        final String clientName = request.getParameter(Pac4jConstants.DEFAULT_CLIENT_NAME_PARAMETER);
        LOGGER.debug("Delegated authentication is handled by client name [{}]", clientName);
        if (hasDelegationRequestFailed(request, response.getStatus()).isPresent()) {
            throw new IllegalArgumentException("Delegated authentication has failed with client " + clientName);
        }

        final J2EContext webContext = Pac4jUtils.getPac4jJ2EContext(request, response);
        if (StringUtils.isNotBlank(clientName)) {
            final Service service = restoreAuthenticationRequestInContext(context, webContext, clientName);
            final BaseClient<Credentials, CommonProfile> client = findDelegatedClientByName(request, clientName, service);

            final Credentials credentials;
            try {
                credentials = client.getCredentials(webContext);
                LOGGER.debug("Retrieved credentials from client as [{}]", credentials);
                if (credentials == null) {
                    throw new IllegalArgumentException("Unable to determine credentials from the context with client " + client.getName());
                }
            } catch (final Exception e) {
                LOGGER.info(e.getMessage(), e);
                throw new IllegalArgumentException("Delegated authentication has failed with client " + client.getName());
            }

            try {
                establishDelegatedAuthenticationSession(context, service, credentials, client);
            } catch (final AuthenticationException e) {
                LOGGER.warn("Could not establish delegated authentication session [{}]. Routing to [{}]", e.getMessage(), CasWebflowConstants.TRANSITION_ID_AUTHENTICATION_FAILURE);
                return new EventFactorySupport().event(this, CasWebflowConstants.TRANSITION_ID_AUTHENTICATION_FAILURE, new LocalAttributeMap<>(CasWebflowConstants.TRANSITION_ID_ERROR, e));
            }
            return super.doExecute(context);
        }

        prepareForLoginPage(context);

        if (response.getStatus() == HttpStatus.UNAUTHORIZED.value()) {
            return stopWebflow();
        }
        return error();
    }

5 GenericOAuth20Client

GenericOAuth20Client是paj4c 提供的類,

繼承的OAuth20Client 有程式碼: 設定了各種元件,包括憑據解析器,認證器等。。

protected void clientInit() {
        this.defaultRedirectActionBuilder(new OAuth20RedirectActionBuilder(this.configuration, this));
        this.defaultCredentialsExtractor(new OAuth20CredentialsExtractor(this.configuration, this));
        this.defaultAuthenticator(new OAuth20Authenticator(this.configuration, this));
        this.defaultProfileCreator(new OAuth20ProfileCreator(this.configuration, this));
    }

其getCredentials方法為:可知,先初始化,然後 嘗試解析憑據。

解析憑據時,先拿到憑據,然後authenticator 進行驗證。

解析憑據使用的是 OAuth20CredentialsExtractor 而驗證時使用  OAuth20Authenticator

public final C getCredentials(WebContext context) {
        this.init();
        C credentials = this.retrieveCredentials(context);
        if (credentials == null) {
            context.getSessionStore().set(context, this.getName() + "$attemptedAuthentication", "true");
        } else {
            this.cleanAttemptedAuthentication(context);
        }

        return credentials;
    }

protected C retrieveCredentials(WebContext context) {
        try {
            C credentials = this.credentialsExtractor.extract(context);
            if (credentials == null) {
                return null;
            } else {
                long t0 = System.currentTimeMillis();
                boolean var12 = false;

                try {
                    var12 = true;
                    this.authenticator.validate(credentials, context);
                    var12 = false;
                } finally {
                    if (var12) {
                        long t1 = System.currentTimeMillis();
                        this.logger.debug("Credentials validation took: {} ms", t1 - t0);
                    }
                }

                long t1 = System.currentTimeMillis();
                this.logger.debug("Credentials validation took: {} ms", t1 - t0);
                return credentials;
            }
        } catch (CredentialsException var14) {
            this.logger.info("Failed to retrieve or validate credentials: {}", var14.getMessage());
            this.logger.debug("Failed to retrieve or validate credentials", var14);
            return null;
        }
    }

6 憑據解析和驗證

在解析時,發現回撥裡面的code,放入Cridential 裡面

protected OAuth20Credentials getOAuthCredentials(WebContext context) {
        String stateParameter;
        String message;
        if (((OAuth20Configuration)this.configuration).isWithState()) {
            stateParameter = context.getRequestParameter("state");
            if (!CommonHelper.isNotBlank(stateParameter)) {
                message = "Missing state parameter: session expired or possible threat of cross-site request forgery";
                throw new OAuthCredentialsException("Missing state parameter: session expired or possible threat of cross-site request forgery");
            }

            message = ((OAuth20Configuration)this.configuration).getStateSessionAttributeName(this.client.getName());
            String sessionState = (String)context.getSessionStore().get(context, message);
            context.getSessionStore().set(context, message, (Object)null);
            this.logger.debug("sessionState: {} / stateParameter: {}", sessionState, stateParameter);
            if (!stateParameter.equals(sessionState)) {
                String message = "State parameter mismatch: session expired or possible threat of cross-site request forgery";
                throw new OAuthCredentialsException("State parameter mismatch: session expired or possible threat of cross-site request forgery");
            }
        }

        stateParameter = context.getRequestParameter("code");
        if (stateParameter != null) {
            message = OAuthEncoder.decode(stateParameter);
            this.logger.debug("code: {}", message);
            return new OAuth20Credentials(message);
        } else {
            message = "No credential found";
            throw new OAuthCredentialsException("No credential found");
        }
    }

驗證時:通過

((OAuth20Service)((OAuth20Configuration)this.configuration).buildService(context, this.client, (String)null)).getAccessToken(code);

使用code 拿到accessToken,並設定在Cridential裡面。

protected void retrieveAccessToken(WebContext context, OAuthCredentials credentials) {
        OAuth20Credentials oAuth20Credentials = (OAuth20Credentials)credentials;
        String code = oAuth20Credentials.getCode();
        this.logger.debug("code: {}", code);

        OAuth2AccessToken accessToken;
        try {
            accessToken = ((OAuth20Service)((OAuth20Configuration)this.configuration).buildService(context, this.client, (String)null)).getAccessToken(code);
        } catch (InterruptedException | ExecutionException | IOException var7) {
            throw new HttpCommunicationException("Error getting token:" + var7.getMessage());
        }

        this.logger.debug("accessToken: {}", accessToken);
        oAuth20Credentials.setAccessToken(accessToken);
    }

如果流程都成功,則拿到accessToken 驗證成功。

注意:這裡返回的Cridentials 實現類是OAuth20Credentials ,在cas 中會轉化為 ClientCredential

就可以自己實現CAS 的   AuthenticationHandler 介面,在裡面拿到AccessToken ,,然後獲取使用者資訊並實現業務了。推薦繼承 AbstractPac4jAuthenticationHandler 進行自定義。

貌似CAS 裡面這一步也做了部分工作,在配置檔案中可以寫,能得到userProfile 。

cas.authn.pac4j.oauth2[1].authUrl=https://open.weixin.qq.com/connect/qrconnect
cas.authn.pac4j.oauth2[1].tokenUrl=https://api.weixin.qq.com/sns/oauth2/access_token
cas.authn.pac4j.oauth2[1].profileUrl=https://api.weixin.qq.com/sns/userinfo
cas.authn.pac4j.oauth2[1].clientName=WeChat