CAS是由耶鲁大学开发的单点登录系统,其核心的知识点包括以下几个概念:
1) TGT: 票据,或称大令牌,在登录成功之后生成,其中包含了用户信息
2) TGC: TGT的key,TGT存储在session中,TGC以cookie形式保存在浏览器中,当再次访问CAS时,会根据TGC去查找对应的TGT
3) ST: 小令牌,由TGT生成,默认使用一次就失效
业务系统前后端分离情况下基于CAS完成单点登录的时序图如图所示:
假设业务系统域名为mail.test.com,cas服务的域名为cas.test.com
1) 在浏览器首先打开mail.test.com,由于cookie以及storage中都没有token信息,故跳转到CAS服务中,url为cas.test.com/login?service=mail.test.com
2) CAS服务返回登录页面,用户输入用户名密码进行登录
3) CAS校验用户名密码,校验成功,则根据用户信息生成TGT、TGC,并将TGC写入到浏览器的cookie中,同时根据TGT生成ST,再重定向到mail.test.com?ticket=ST
4) 浏览器再次接收到mail.test.com请求,由于此时携带了ticket,故调用后端服务接口
5) 后端服务收到ticket,调用cas.test.com/p3/seviceValidate校验ticket,若校验通过,则返回的校验信息中携带了用户信息;根据用户信息生成token返回给前端
6) 前端将token保存到浏览器的cookie或storage中
这里需要注意的时CAS只是进行身份认证,token的生成需要业务系统自己实现
假设此时另一个业务系统bussiness.test.com也进入了CAS,在浏览器首次打开bussiness.test.com时,由于当前域名下没有存储token,则也会跳转到CAS中,url为cas.test.com/login?service=bussiness.test.com;此时CAS域名下的cookie中存储了TGC,则CAS服务根据TGC可以查找到TGT,再根据TGT生成ST,重定向到bussiness.test.com?ticket=ST,后面的流程与前述相同。
CAS默认是通过用户名密码登录,在业务需要场景下,需要将登录方式修改为基于短信验证码登录,为此需要对CAS进行二次开发。
CAS源码中使用UsernamePasswordCredentail类来实现登录凭证,在此定义一个新的类来实现验证码登录凭证:
public class PhoneCaptchaCredential implements Credential {
private static final long serialVersionUID = -1616013347177519641L;
@Size(min = 1, message = "required.phone")
private String phone;
@Size(min = 1, message = "required.captcha")
private String captcha;
@Override
public String getId() {
return this.phone;
}
@Generated
public PhoneCaptchaCredential() {
}
@Generated
public PhoneCaptchaCredential(String phone, String captcha) {
this.phone = phone;
this.captcha = captcha;
}
}
重写DefaultLoginWebflowConfigurer.createRememberMeAuthnWebflowConfig的逻辑:
@Override
protected void createRememberMeAuthnWebflowConfig(Flow flow) {
if (casProperties.getTicket().getTgt().getRememberMe().isEnabled()) {
createFlowVariable(flow, CasWebflowConstants.VAR_ID_CREDENTIAL, PhoneCaptchaCredential.class);
final ViewState state = getState(flow, CasWebflowConstants.STATE_ID_VIEW_LOGIN_FORM, ViewState.class);
final BinderConfiguration cfg = getViewStateBinderConfiguration(state);
cfg.addBinding(new BinderConfiguration.Binding("phone", null, false));
cfg.addBinding(new BinderConfiguration.Binding("captcha", null, true));
} else {
createFlowVariable(flow, CasWebflowConstants.VAR_ID_CREDENTIAL, UsernamePasswordCredential.class);
}
}
在配置文件中需添加 cas.ticket.tgt.rememberMe.enabled=true
自定义handler完成认证流程:
public class CasAuthenticationHandler extends AbstractPreAndPostProcessingAuthenticationHandler {
private static final org.slf4j.Logger logger = LoggerFactory.getLogger(CasAuthenticationHandler.class);
public CasAuthenticationHandler(String name, ServicesManager servicesManager, PrincipalFactory principalFactory, Integer order) {
super(name, servicesManager, principalFactory, order);
}
private AuthenticationHandlerExecutionResult doPhoneAuthentication(
PhoneCaptchaCredential phoneCaptchaCredential, JdbcTemplate jdbcTemplate)
throws GeneralSecurityException {
String phone = phoneCaptchaCredential.getPhone();
String captcha = phoneCaptchaCredential.getCaptcha();
JedisPool pool = new JedisPool(new JedisPoolConfig(), "xx.xx.xx.xx", 6379);
Jedis redis = pool.getResource();
String redisCode = redis.get("CAS-" + phone);
if (null == redisCode) {
logger.error("验证码已过期");
throw new AccountException("验证码已过期!");
}
if (!StringUtils.equals(redisCode, captcha)) {
logger.error("验证码错误");
throw new AccountException("验证码错误!");
}
String sql = "select * from cas_user where phone = ? and status = 0";
User info = (User) jdbcTemplate.queryForObject(sql, new Object[]{phone}, new BeanPropertyRowMapper(User.class));
if (info == null) {
logger.error("用户不存在或账户已锁定");
throw new AccountException("用户不存在或账户已锁定");
}
final List<MessageDescriptor> list = new ArrayList<>();
/**可自定义返回给客户端的多个属性信息**/
HashMap<String, Object> returnInfo = new HashMap<>();
returnInfo.put("status", info.getStatus());
returnInfo.put("deleted", info.getDeleted());
returnInfo.put("name", info.getName());
returnInfo.put("sex", info.getSex());
returnInfo.put("age", info.getAge());
return createHandlerResult(phoneCaptchaCredential,
this.principalFactory.createPrincipal(info.getUsername(), returnInfo), list);
}
@Override
protected AuthenticationHandlerExecutionResult doAuthentication(Credential credential) throws GeneralSecurityException, PreventedException {
// 先构建数据库驱动连接池
DriverManagerDataSource dataSource = new DriverManagerDataSource();
dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver");
dataSource.setUrl("jdbc:mysql://xx.xx.xx.xx:3306/test");
dataSource.setUsername("xxxx");
dataSource.setPassword("xxxxxx");
return doPhoneAuthentication((PhoneCaptchaCredential) credential, jdbcTemplate);
}
@Override
public boolean supports(Credential credential) {
return credential instanceof PhoneCaptchaCredential;
}
}
最后定义AuthConfig:
@Configuration("MyAuthConfig")
@EnableConfigurationProperties(CasConfigurationProperties.class)
public class MyAuthConfig implements AuthenticationEventExecutionPlanConfigurer {
@Autowired
private CasConfigurationProperties casProperties;
@Autowired
@Qualifier("servicesManager")
private ServicesManager servicesManager;
@Autowired
@Qualifier("loginFlowRegistry")
private FlowDefinitionRegistry loginFlowRegistry;
@Autowired
private ApplicationContext applicationContext;
@Autowired
private FlowBuilderServices flowBuilderServices;
@Bean
public PrePostAuthenticationHandler myAuthenticationHandler() {
return new CasAuthenticationHandler(CasAuthenticationHandler.class.getName(),
servicesManager, new DefaultPrincipalFactory(), 1);
}
@Override
public void configureAuthenticationExecutionPlan(AuthenticationEventExecutionPlan plan) {
plan.registerAuthenticationHandler(myAuthenticationHandler());
}
@Bean("defaultLoginWebflowConfigurer")
public CasWebflowConfigurer defaultLoginWebflowConfigurer() {
DefaultCaptchaWebflowConfigurer c = new DefaultCaptchaWebflowConfigurer(flowBuilderServices, loginFlowRegistry, applicationContext, casProperties);
c.initialize();
return c;
}
}
修改templates中html文件,完成页面适配修改: