Apereo CAS增加登录方式。
一些废话
学习一个协议或者理论,个人一直纠结于先了解流程还是先看术语。
先看流程吧,里面可能提到了术语不知道;
先看术语,有可能术语太多,而且描述的不够详尽导致看了以后还是一头雾水,而且有可能因为不了解过程,心里预先产生一些概念而误导了之后对流程的阅读。
所以个人觉得稍微好点的方式是先能了解一些脱离了术语的流程概览,然后了解关键
术语,然后了解详细流程,碰到不会的术语再会查。
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。
也算是运气好,要不可能一直会有错误的理解了。