一些廢話
學習一個協議或者理論,個人一直糾結於先了解流程還是先看術語。
先看流程吧,里面可能提到了術語不知道;
先看術語,有可能術語太多,而且描述的不夠詳盡導致看了以后還是一頭霧水,而且有可能因為不了解過程,心里預先產生一些概念而誤導了之后對流程的閱讀。
所以個人覺得稍微好點的方式是先能了解一些脫離了術語的流程概覽,然后了解關鍵
術語,然后了解詳細流程,碰到不會的術語再會查。
CAS協議簡介
前言
CAS(Central Authentication Service),翻譯過來是集中認證服務,作用是單點登錄(SSO)的認證服務協議。
涉及到單點登錄時,一般有三個角色,用戶/客戶端(Browser),應用服務(CAS Client),單點登錄服務器(CAS Server)。
Browser訪問CAS Client,未認證時,CASClient會重定向到CASServer要求用戶認證,認證完會攜帶認證信息再重定向用戶到CASClient登錄。了解OAuth的話,兩個流程概念上類似。
幾個概念
CAS Server
: CAS服務器,負責權限驗證,授權。Service
:(注意不是server)注冊在CAS Server上的一個服務,訪問服務需要鑒權,鑒權方式是用戶提供一個ST
(見下),Service會用這個ST
去CASServer驗證有效性,並獲得這個ST
對應的用戶身份。CAS Client
: CAS客戶端,和CAS Server進行交互,有兩個理解,可以理解成Service本身,也可以理解成Service集成。TGT
: Ticket Granting Ticket. 這是用戶登錄后,CAS Server發給用戶的一個票據(一串TGT-
開頭的字符串),表示登錄成功的狀態,一般存在用戶瀏覽器的Cookie中。當用戶再次訪問CAS Server時,如果CAS Server檢測到存在TGT,那么就不需要用戶再次輸入賬號密碼,而是可以直接下發登錄成功的憑證(也就是下面提到的ST)ST
:Service Ticket,用戶訪問一個Service時出示的憑證,一般就是一個字符串,以ST-
開頭。用戶在CASServer驗證通過后,CASServer會針對不同的Service頒發一個ST
,用戶把這個ST
發給Service,Service會再拿着這個ST去CASServer驗證,驗證通過CASServer會返回用戶名,以及用戶相關的基本信息。Service可以用這些信息在自己的系統上做。
這里放一個CAS官方的流程示例,對理解很有幫助。。里面的Protected APP可以理解為CAS Client/Service。
Apereo CAS增加登錄方式
背景
默認CAS登錄校驗時,返回的主要信息就是principal用戶名,我們的用戶表中,現在除了用戶名,還有用戶姓名、單位、電話等字段,這些字段(包括用戶名),都可能為空,於是現在有這么一個需求,想要用戶能
- 通過輸入單位和姓名登錄(當然系統保證同一單位下沒有重復用戶名)
- 通過電話登陸
這個需求不那么正常,但是既然要求,也要想辦法做。
這里記錄的關鍵不是業務上如何從數據庫定位用戶,而是如何使用Apereo CAS支持這么一個場景。
Apereo CAS一些背景知識
其實在其官方網站上有,我沒有全看,看了一些基本的Authentication章節有個了解。
Server端
通過Maven Overlay方式開發,簡單說就是Apereo庫把整個工程已經做好了,我們需要單獨定制的頁面(比如登錄頁),單獨拿出來一份放到跟原始工程一樣的路徑以相同的名字命名,修改打包就會覆蓋工程里的原始文件。
而服務端用的框架是Spring Webflow,通過xml配置文件配置一些頁面的邏輯跳轉關系,輸入輸出參數。實際頁面是HTMl頁面,但是用了thymeleaf模板工具。
而項目中使用Spring Weblfow,不僅僅通過配置文件,還使用了代碼配置,需要的前置知識有點多,個人目前也沒吃透。
涉及到認證模型,是通過AuthenticationHandler
這個接口的實現,而接口的關鍵方法就是兩個,supports
和authenticate
,和Spring Security的AuthenticationProvider很像。而認證涉及關鍵接口Credential
代表了認證是用戶發送過來的參數。
Server端的修改就圍繞這個AuthenticationHandler
和Credential
展開。
Client端
利用jasig cas(也是Apereo的一個組件)做客戶端驗證。
首先未接入單點時,項目原有就使用了JWT認證,這部分不做修改。
修改部分不侵入Spring Security框架,而是單獨擴展一個接口處理CAS Server重定向過來的含有ST的登陸請求,這里利用了Cas30JsonServiceTicketValidator
這個類做驗證,這個類內部會基於CAS協議,向CAS服務器發送請求驗證ST,同時獲取到服務器返回的信息進一步處理(不過這塊我碰到一個坑,后面說)。
驗證成功后,生成一個JWT token返回前端,前端后續訪問使用JWT token訪問,於是就可以利用之前未接入單點時的流程。
代碼修改
Server端修改
思路:
-
登錄時,增加傳遞一個類型參數
type
,表示登錄的類型。同時增加額外參數傳遞比如用戶單位的字段 -
增加一個Authentication的實現類處理用戶名、警號、手機號的登錄。
-
登錄成功時除了返回用戶名(原有的邏輯),補充返回字段:type, 手機號,部門,姓名,這樣客戶端可以根據這幾個字段判斷用戶是如何登陸的。
- 額外的,由於CAS認證時,不允許返回空的用戶名,當用戶名為空時,在服務端這邊設置一個魔數字
__CUSTOM_EMPTY_USER_NAME
,客戶端收到時可以發現是這個用戶名,就知道用戶名無效,而通過其他參數去確認這個用戶。
- 額外的,由於CAS認證時,不允許返回空的用戶名,當用戶名為空時,在服務端這邊設置一個魔數字
-
自定義一個
RealNameOrUsernamePasswordCredential
類擴展自UsernamePasswordCredential
,UsernamePasswordCredential
是框架自帶的類,包含用戶名和密碼,而我要做的是增加部門、和登陸方式兩個屬性。
//省略了getter setter等不重要內容
public class RealNameOrUsernamePasswordCredential extends UsernamePasswordCredential {
private static final long serialVersionUID = 4925857296347525871L;
/**
* represent an empty user name, otherwise cas won't pass auth
*/
public static final String EMPTY_USER_NAME = "__CUSTOM_EMPTY_USER_NAME";
/**
* via user code(username)
*/
public static final int AUTH_TYPE_USER_NAME = 1;
/**
* via mobile phone
*/
public static final int AUTH_TYPE_MOBILE = 2;
/**
* via org + realName
*/
public static final int AUTH_TYPE_REAL_NAME = 3;
/**
* type of authentication AUTH_TYPE_XX,
* 1-username is user code(username).
* 2-username is mobile
* 3-username is user's real name
*/
private Integer type;
/**
* 2nd level orgId
*/
private Integer orgId;
}
- 創建對應的AuthenticationHandler類,並注入:
public class RealNameOrUsernameAuthentication extends AbstractUsernamePasswordAuthenticationHandler {
private static final Logger LOGGER = LoggerFactory.getLogger(RealNameOrUsernameAuthentication.class);
@Autowired
CscpUserService userService;
@Autowired
CscpOrgService orgService;
PasswordEncoder passwordEncoder = new org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder();
public RealNameOrUsernameAuthentication(String name, ServicesManager servicesManager,
PrincipalFactory principalFactory, Integer order) {
super(name, servicesManager, principalFactory, order);
LOGGER.info("RealNameOrUsernameAuthentication created");
}
@Override
public boolean supports(Credential credential) {
//傳入的是我們新定義的RealNameOrUsernamePasswordCredential才可用這個類
if (RealNameOrUsernamePasswordCredential.class.isInstance(credential)) {
LOGGER.debug("Supported RealNameOrUsernamePasswordCredential [{}]", credential);
return true;
}
//only support Realname credential
return false;
}
@Override
protected AuthenticationHandlerExecutionResult doAuthentication(Credential credential) throws GeneralSecurityException, PreventedException {
RealNameOrUsernamePasswordCredential rc = (RealNameOrUsernamePasswordCredential) credential;
CscpUserDTO user = null;
//fetch user by different auth type
LOGGER.info("Try authenticate with auth type: {}", rc.getActualAuthType());
//根據不同的類型定位用戶
switch (rc.getActualAuthType()) {
case RealNameOrUsernamePasswordCredential.AUTH_TYPE_USER_NAME:
//用戶名
user = userService.getByUsername(rc.getUsername());
break;
case RealNameOrUsernamePasswordCredential.AUTH_TYPE_MOBILE:
//手機號
user = userService.getByMobile(rc.getUsername());
break;
case RealNameOrUsernamePasswordCredential.AUTH_TYPE_REAL_NAME: {
//通過單位+姓名
if (null == rc.getOrgId()) {
throw new AuthenticationException("Org id is missing");
}
CscpUserQuery query = new CscpUserQuery();
query.setPageNumber(1);
query.setPageSize(1);
query.setOrgId(rc.getOrgId());
query.setRealNamePrecise(rc.getUsername());
PageResult<CscpUserDTO> pageResult = userService.searchUserByOrgId(query);
if ((null == pageResult) || (pageResult.getRecordsTotal() <= 0)) {
throw new AccountException("User name is not found at this org");
} else if (pageResult.getRecordsTotal() > 1) {
throw new AccountException("Multiple user found for this user name");
}
user = pageResult.getData().get(0);
}
break;
default:
throw new AuthenticationException("Unknown auth type");
}
if (null == user) {
throw new AccountException("User not found");
}
if (!passwordEncoder.matches(rc.getPassword(), user.getPassword())) {
throw new FailedLoginException("Sorry, password not correct!");
} else {
//自定義返回給客戶端的多個屬性信息
HashMap<String, Object> returnInfo = new HashMap<>();
//姓名
returnInfo.put("realName", user.getRealName());
//手機號
returnInfo.put("tel", user.getTel());
//認證類別
returnInfo.put("authType", rc.getActualAuthType());
if (RealNameOrUsernamePasswordCredential.AUTH_TYPE_REAL_NAME == rc.getActualAuthType()) {
//增加用戶實際所屬部門編碼
List<CscpOrgDTO> userLoginOrg = orgService.getByIds(Arrays.asList(new Long[]{rc.getOrgId()}));
if ((null == userLoginOrg) || (1 != userLoginOrg.size())) {
LOGGER.error("Invalid user org info: {}", userLoginOrg);
throw new LoginException("Invalid user org info");
}
returnInfo.put("orgCode", userLoginOrg.get(0).getOrg_code());
}
final List<MessageDescriptor> list = new ArrayList<>();
//這里如果不配置用戶名,客戶端驗證不成功,所以對於沒有用戶名的情況,增加一個默認用戶名,交給客戶端特殊處理
String userName = user.getUserName();
if (StringUtils.isBlank(userName)) {
userName = RealNameOrUsernamePasswordCredential.EMPTY_USER_NAME;
}
return createHandlerResult(rc,
this.principalFactory.createPrincipal(userName, returnInfo), list);
}
}
@Override
protected AuthenticationHandlerExecutionResult authenticateUsernamePasswordInternal(UsernamePasswordCredential credential, String originalPassword) throws GeneralSecurityException, PreventedException {
return null;
}
}
@Configuration("CustomAuthenticationConfiguration")
@EnableConfigurationProperties(CasConfigurationProperties.class)
@ComponentScan("com.msk.cas.config.realname")
public class CustomAuthenticationConfiguration implements AuthenticationEventExecutionPlanConfigurer {
@Autowired
private CasConfigurationProperties casProperties;
@Autowired
@Qualifier("servicesManager")
private ServicesManager servicesManager;
@Bean
public AuthenticationHandler realNameAuthenticationHandler() {
// 把自定義的AuthenticationHandler放到第一個位
return new RealNameOrUsernameAuthentication(RealNameOrUsernameAuthentication.class.getName(),
servicesManager, new DefaultPrincipalFactory(), 1/*** order */);
}
@Override
public void configureAuthenticationExecutionPlan(final AuthenticationEventExecutionPlan plan) {
//注入自定義的AuthenticationHandler
plan.registerAuthenticationHandler(realNameAuthenticationHandler());
}
@Autowired
@Qualifier("loginFlowRegistry")
private FlowDefinitionRegistry loginFlowRegistry;
@Autowired
private ApplicationContext applicationContext;
@Autowired
private FlowBuilderServices flowBuilderServices;
@Bean("defaultWebflowConfigurer")
public CasWebflowConfigurer customWebflowConfigurer() {
//配置webflow,主要是修改登陸頁面的Credential參數類型,對應上面自定義AuthenticationHandler的RealNameOrUsernamePasswordCredential
//RealNameOrUsernameWebflowConfigurer的定義見下
DefaultLoginWebflowConfigurer c = new RealNameOrUsernameWebflowConfigurer(flowBuilderServices, loginFlowRegistry,
applicationContext, casProperties);
c.initialize();
return c;
}
}
- 要讓webflow的登錄頁面能傳遞這個參數,所以要修改weblow相關流程。上一步已經注冊了,這里給出定義。
public class RealNameOrUsernameWebflowConfigurer extends DefaultLoginWebflowConfigurer {
private static final Logger LOGGER = LoggerFactory.getLogger(RealNameOrUsernameWebflowConfigurer.class);
public RealNameOrUsernameWebflowConfigurer(FlowBuilderServices flowBuilderServices, FlowDefinitionRegistry flowDefinitionRegistry,
ApplicationContext applicationContext, CasConfigurationProperties casProperties) {
super(flowBuilderServices, flowDefinitionRegistry, applicationContext, casProperties);
}
@Override
protected void createRememberMeAuthnWebflowConfig(Flow flow) {
createFlowVariable(flow, CasWebflowConstants.VAR_ID_CREDENTIAL, RealNameOrUsernamePasswordCredential.class);
final ViewState state = getState(flow, CasWebflowConstants.STATE_ID_VIEW_LOGIN_FORM, ViewState.class);
final BinderConfiguration cfg = getViewStateBinderConfiguration(state);
//add custom binding
cfg.addBinding(new BinderConfiguration.Binding("type", null, true));
cfg.addBinding(new BinderConfiguration.Binding("parentOrgId", null, false));
cfg.addBinding(new BinderConfiguration.Binding("orgId", null, false));
}
}
- 然后就是webflow頁面和xml配置文件的修改了,頁面修改需要增加對應的input框,這里不做介紹,xml文件修改
login-webflow.xml
的viewLoginForm
部分(只粘貼修改部分)
<action-state id="initializeLoginForm">
<evaluate expression="initializeLoginAction" />
<transition on="success" to="viewLoginForm"/>
</action-state>
<view-state id="viewLoginForm" view="casLoginView" model="credential">
<binder>
<binding property="username" required="true"/>
<binding property="password" required="true"/>
<-- 以下三個是新增參數 -->
<binding property="type" required="true"/>
<binding property="parentOrgId" required="false"/>
<binding property="orgId" required="false"/>
</binder>
<transition on="submit" bind="true" validate="true" to="realSubmit" history="invalidate"/>
</view-state>
Client端修改
這里不對原有邏輯進行詳細描述,主要說明修改點。
- 收到ST后進行校驗
- 校驗成功判斷用戶名是否存在
- 存在則邏輯不變,通過用戶名查找用戶,生成jwt token
- 不存在則獲取額外屬性,根據額外屬性查找用戶,生成token
擴展的地方看起來不難,只是業務上的增加額外獲取用戶的方法就可以,CAS3.0中,提供了Attributes這樣一個Map結構的字段可以獲取額外屬性。取到這些屬性以后,業務上如何操作都沒有什么難度了。
//校驗代碼
//serverUrl就是Server的地址
//Cas30JsonServiceTicketValidator是jagis包自帶的ST驗證類
Cas30JsonServiceTicketValidator validator = new Cas30JsonServiceTicketValidator(serverUrl);
//ticket是server頒發的ST字符串,serviceUrl是客戶端向Server注冊的地址
//validator驗證默認成功,返回assertion
Assertion assertion = validator.validate(ticket, serviceUrl);
Cas30JsonServiceTicketValidator驗證成功返回Assertion,Assertion有兩個方法:getAttributes和getPrincipal。
我一開始以為Server返回的屬性在getAttributes中,結果發現這個getAttributes返回的是空,以為是組件的問題。。。最后發現其實getPrincipal()返回的Principal里,還有一個getAttributes方法,這個里面有Server返回的我所增加的屬性:assertion.getPrincipal().getAttributes()
。
結語
印象最深的還是在client那里走的彎路,我以為是apereo代碼問題,把里面的工具類(從Validator開始)重寫了,結果今天在補充這篇記錄時,突然發現Pricipal里面的Attributes。
也算是運氣好,要不可能一直會有錯誤的理解了。