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