需求描述
最近客户有一个需求,希望在企业微信的工作台上放一些业务系统的链接。这些业务系统本身已经完成了和CAS单点的对接,但是放在企业微信工作台上就会出现问题。点击系统链接的时候,会先被CAS拦截下来,跳转到单点登录页。用户在输入完账号密码后,才能进入对应系统。这样就很不方便,希望可以实现点击对应系统的链接之后,可以自动实现认证过程,直接进入系统。
方案说明
我们可以利用企业微信本身提供的Oauth2认证来实现这个需求。通过企业微信的Oauth2认证,我们可以安全获取当前微信的登录用户。我们再根据这个用户去CAS创建票据,即可成功实现企业微信和CAS之间的组合认证。
企业微信提供了一个链接:
https://open.weixin.qq.com/connect/oauth2/authorize?appid=CORPID&redirect_uri=REDIRECT_URI&response_type=code&scope=SCOPE&agentid=AGENTID&state=STATE#wechat_redirect
这个特殊的链接,只能在企业微信客户端打开(这里提供的是公共版企业微信的地址,如果企业微信是本地化部署的,则对应的域名要换成企业微信本地服务器的地址),在企业微信打开后,它会自动跳转到redirect_uri所对应的地址,并携带两个参数:code
和 state
。这两个参数,code是我们所需要的,我们需要用这个 code
去调用企业微信另一个接口,用于获取用户的id。而 state
则是可以用来携带一些信息,会在重定向时原样带回。在这里我们不需要使用,只需要 code
即可。
那么我们的步骤就很明确了:
- 在企业微信工作台上,将对应应用的链接配置成上面那样,
redirect_uri
配置成CAS的地址,并且携带对应的service参数。假设CAS服务器的登录地址是https://test.cas.com/cas/login
,对应的业务系统地址是https://www.baidu.com
。那么对于正常的CAS登录,登录页的url应该是https://test.cas.com/cas/login?service=https://www.baidu.com
。而这里,我们就要将这个url配置成企业微信认证的redirect_uri
。但这里需要注意一点,企业微信要求这里的redirect_uri必须是经过urlEncode过的,所以我们要先对这个登录页url进行一次Encode。但这里还有一些特殊,因为我们的url中包含客户端业务系统的url,所以我们需要进行二次Encode,即先对业务系统的url进行一次Encode,变成下面这样:https://test.cas.com/cas/login?service=https%3A%2F%2Fwww.baidu.com
,然后再对整个url进行一次Encode,变成:https%3A%2F%2Ftest.cas.com%2Fcas%2Flogin%3Fservice%3Dhttps%253A%252F%252Fwww.baidu.com
。这样才能放入企业微信认证链接的redirect_uri
中。 - 在企业微信后台管理中,将应用的可信域名配置成单点服务器的域名(需要包括端口号)。否则在后面验证code的时候,会重定向域名不是可信域名。
- 用户点击应用,企业微信跳转到单点登录页,并携带code。这个时候我们需要让CAS自动识别这个code,并提交到后端进入认证流程。这里的详细操作会放在下面展开来介绍。
- CAS后端接收到企业微信提供的code,调用企业微信的
https://open.weixin.qq.com/cgi-bin/user/getuserinfo?access_token=ACCESS_TOKEN&code=CODE
接口,将code放入接口中,接口会返回用户对应的id。 - 如果企业微信的用户id就是CAS使用的用户名,那可以直接使用用户id创建票据。如果不是,则需要通过企业微信的用户id匹配到对应的用户名,然后再创建票据。如果没有建立企业微信用户id与用户名之间的映射关系,我们也可以通过调用企业微信提供的通讯录接口,通过用户id获取用户详细信息如手机号或者邮箱等,再通过这些信息进行匹配。
- 创建完票据,就可以走正常的CAS登录流程了,结束。
可以看到,整个方案最关键的点,就是让CAS拿到企业微信提供的code,并通过这个code来获取当前登录人。
CAS的改造
在上面的第3点,我们提到要让CAS自动识别到url中的code,然后提交到后端进入认证流程。这一步要如何实现呢?我这里提供一种思路,就是通过js来判断url中是否含有code参数,如果有,则代表这个请求是通过企业微信重定向过来的,那我们就需要特殊处理。
首先我们需要一个工具方法,用来获取url中的参数
/**
* 从location中得到参数
* @param location
* @param name
* @returns {null}
*/
var getQueryString = function (location, name) {
var reg = new RegExp('(^|&)' + name + '=([^&]*)(&|$)');
var r = location.search.substr(1).match(reg);
if (r !== null) return unescape(r[2]);
return null
};
let code = getQueryString(window.location, 'code');
这样,我们就可以从url中拿到code的值了。接下来我们需要考虑如何将这个code传到后端。这里我提供两种思路:
- 通过表单进行传递,将code作为账号,密码采用一个特殊值,后端识别到这个特殊值,就代表是企业微信传输过来的(不推荐,因为不能保证没有人会用这个特殊值作为密码,即使概率很小)
- 放入cookie中,后端判断cookie中有code,则走企业微信认证逻辑,否则走正常逻辑。
这里提供一下放入cookie的代码:
var setEnterpriseWechatCodeCookie = function (code) {
document.cookie = "authCode=" + code;
};
setEnterpriseWechatCodeCookie(code);
下面,怎么将这个code提交到后端呢?这里用比较取巧的方法,走正常的表单提交,只不过账号密码里面填上无意义的值。
document.getElementById("username").setAttribute("value", "_");
document.getElementById("password").setAttribute("value", "_");
document.getElementById("login-btn").click();
可以看到,这里模拟了登录按钮的点击操作,这样就可以将code提交到后端了。
但仅仅是这样,还是有一些瑕疵。在实际的效果中,这样的确可以实现自动识别code,并传给后端。但是登录界面会一闪而过,比较影响体验。那么我们为了让用户无感,要想办法让用户看不到这个登录界面。
这里我采用的方法是:
- 先让整个登录界面都默认不显示,即display:none,然后在整个界面上增加一个纯白的遮罩,之后再让登录界面显示。
- 在判断不需要走企业微信登录逻辑,即走正常登录逻辑时,删除遮罩
这样做的好处是,纯白的遮罩看起来像是网页加载过程中正常的空白,所以对用户来说是相对无感的。
下面给出部分代码:
.blank-mask {
left: 0;
top: 0;
bottom:0;
right:0;
position: fixed;
z-index: 99999;
background: rgb(255,255,255);
}
.html-blank-mask{
height: 100%;
width: 100%;
overflow: hidden;
}
var createMask = function () {
if( document.getElementById("blank-mask")){
return true;
}
let mask = document.createElement("div");
mask.id = "blank-mask";
mask.className = "blank-mask";
// 把 mask 添加到body 里。
document.body.appendChild(mask);
document.documentElement.classList.add("html-blank-mask");
}
var deleteMask = function () {
let mask = document.getElementById("blank-mask");
if(mask){
mask.parentNode.removeChild(mask);
document.documentElement.classList.remove("html-blank-mask");
}
}
$(function () {
createMask();
$("#all").show();
let code = getQueryString(window.location, 'code');
if (code) {
setEnterpriseWechatCodeCookie(code);
document.getElementById("username").setAttribute("value", "_");
document.getElementById("password").setAttribute("value", "_");
document.getElementById("login-btn").click();
} else {
deleteMask();
}
});
到这里为止,前端代码算是结束了,我们可以实现当企业微信重定向到CAS服务端的时候,可以自动将code传到后端了。那么后端如何使用这个code,下面进行一些简单的介绍。
在表单提交后,会进入到正常的表单登录认证的AuthenticationHandler中,我们需要在执行正常的认证逻辑之前,插入我们的企业微信认证逻辑。由于代码逻辑不算复杂,这里就不多做介绍,只贴上一份代码作为参考。
/**
* 获取企业微信认证code的工具类
* @return
*/
private String getAuthCode() {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
Cookie[] cookies = request.getCookies();
if (null != cookies) {
for (Cookie c : cookies) {
if ("authCode".equalsIgnoreCase(c.getName())) {
return c.getValue();
}
}
}
return null;
}
/**
* 获取企业微信认证token的工具类,token缓存多次使用
* @param isRefresh 是否刷新
* @return
*/
private String getAccessToken(boolean isRefresh) {
Long newTime = System.currentTimeMillis();
if (expiresIn > newTime && !isRefresh) {
return accessToken;
} else {
String tokenUrl = PropertyUtil.getProperty("wx.tokenUrl",MyAuthenticationHandler.class);
JSONObject tokenJson = new JSONObject(restTemplate.getForObject(tokenUrl, String.class,
PropertyUtil.getProperty("wx.corpId",MyAuthenticationHandler.class),
PropertyUtil.getProperty("wx.corpSecret",MyAuthenticationHandler.class)));
if (0 == tokenJson.getInt("errcode")) {
accessToken = tokenJson.getString("access_token");
expiresIn = newTime + tokenJson.getLong("expires_in") * 1000;
return accessToken;
} else {
return getAccessToken(true);
}
}
}
/**
* 通过code调用企业微信接口获取userId
* @param code
* @param accessToken
* @return
*/
private String getWechatUserId(String code, String accessToken) {
String infoUrl = PropertyUtil.getProperty("wx.userInfoUrl",MyAuthenticationHandler.class);
JSONObject infoJson = new JSONObject(restTemplate.getForObject(infoUrl, String.class, accessToken, code));
if (0 == infoJson.getInt("errcode")) {
return infoJson.getString("UserId");
} else if (40014 == infoJson.getInt("errcode")) {
//token过期,重新获取token再次调用接口
return getWechatUserId(code, getAccessToken(true));
}
return null;
}
//企业微信认证
String code = getAuthCode();
if (StringUtils.isNotBlank(code)) {
//获取token
String token = getAccessToken(false);
//获取userId
String userId = getWechatUserId(code, token);
//创建票据,通过认证
if (StringUtils.isNotBlank(userId)) {
credential.setUsername(userId);
return createHandlerResult(credential,
this.principalFactory.createPrincipal(credential.getUsername()), null);
}
}