需求描述
最近客户有一个需求,希望在企业微信的工作台上放一些业务系统的链接。这些业务系统本身已经完成了和CAS单点的对接,但是放在企业微信工作台上就会出现问题。点击系统链接的时候,会先被CAS拦截下来,跳转到单点登录页。用户在输入完账号密码后,才能进入对应系统。这样就很不方便,希望可以实现点击对应系统的链接之后,可以自动实现认证过程,直接进入系统。
方案说明
我们可以利用企业微信本身提供的Oauth2认证来实现这个需求。通过企业微信的Oauth2认证,我们可以安全获取当前微信的登录用户。我们再根据这个用户去CAS创建票据,即可成功实现企业微信和CAS之间的组合认证。
企业微信提供了一个链接:
https://open./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服务器的登录地址是/cas/login
,对应的业务系统地址是。那么对于正常的CAS登录,登录页的url应该是
/cas/login?service=
。而这里,我们就要将这个url配置成企业微信认证的redirect_uri
。但这里需要注意一点,企业微信要求这里的redirect_uri必须是经过urlEncode过的,所以我们要先对这个登录页url进行一次Encode。但这里还有一些特殊,因为我们的url中包含客户端业务系统的url,所以我们需要进行二次Encode,即先对业务系统的url进行一次Encode,变成下面这样:/cas/login?service=https%3A%2F%
,然后再对整个url进行一次Encode,变成:https%3A%2F%%2Fcas%2Flogin%3Fservice%3Dhttps%253A%252F%
。这样才能放入企业微信认证链接的redirect_uri
中。在企业微信后台管理中,将应用的可信域名配置成单点服务器的域名(需要包括端口号)。否则在后面验证code的时候,会重定向域名不是可信域名。用户点击应用,企业微信跳转到单点登录页,并携带code。这个时候我们需要让CAS自动识别这个code,并提交到后端进入认证流程。这里的详细操作会放在下面展开来介绍。CAS后端接收到企业微信提供的code,调用企业微信的https://open./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)) {//获取tokenString token = getAccessToken(false);//获取userIdString userId = getWechatUserId(code, token);//创建票据,通过认证if (StringUtils.isNotBlank(userId)) {credential.setUsername(userId);return createHandlerResult(credential,this.principalFactory.createPrincipal(credential.getUsername()), null);}}