写在前面:
目前主要做的是政府一块类的项目,其中大大小小项目免不了通过浙政钉来访问进而可以实现免登操作。其实看了之后还蛮简单的,最近项目中又用到了,趁此机会打卡记录学习一下。为啥是2.0,现在和之前的浙政钉免登有点改动了,统一都用2.0这套。
什么是浙政钉?
浙政钉是浙江省政府省、市、县(市、区)、乡镇、村(街道、社区)五级政府人员的组织,是浙江省政府数字化转型的沟通协同平台。深化“最多跑一次”改革推进政府数字化转型。为规范浙政钉整体架构体系,按照统分结合原则,由省政府办公厅统一设计整体工作界面和系统框架,统筹指导全省统建应用建设,各单位根据自身业务特点分别建设自建应用,最终形成全省统一的政府系统掌上协同办公平台。
日常网页中的“记住密码”就是一次免登:
流程:用户登录时,输入用户名、密码等等,再勾“记住密码”选项,成功登录过一次系统,而用户的用户名以及密码等等都保存在cookie中,当用户再次登录时,系统会自动调用Cookie中的数据,自动给用户赋值,从而实现免登陆。
浙政钉免登:
用户通过浙政钉app去应用平台上登录某个系统,第一次登录时,需要输入账号密码,第二次再来进入时,不需要再输入账号密码即可正常登录系统。
“记住密码”VS浙政钉免登 相同点:
1.两者在首次登录时,都需要输入账号和密码。
2. 二次登录时,有了首次登录,即可实现免登操作。
“记住密码”VS浙政钉免登 不同点:
1.“记住密码”账号密码信息保存于浏览器cookie中,一旦清除浏览器缓存,下次还得重新输入账号密码。
2.浙政钉免登登入者信息保存于系统用户表中,因为通过浙政钉进行的登录,拿到钉钉的这个用户信息,比对系统的用户表有没有绑定登录者用户的accountID(钉钉id),有过绑定,说明之前登录过。不需要输入账号密码即可实现登入。
综合得出结论,实现浙政钉免登步骤:
1. 首次登录需要输入账号密码
2. 登录成功,进行绑定(绑定登录者用户accountID(钉钉id)和系统用户表进行绑定)
3. 二次登录时,判断登录者accountID(钉钉id)是否和系统用户表有过绑定
4. 有过绑定,即返回token无需再输账号密码。否则,跳转到登录页,手动进行账号密码登录。
Ps:现在项目是SpringCloud微服务使用Nacos做注册中心。登录有专属的一个authentication-server服务,进行登录的校验。登录成功,返回一个token给前端。
没实际上手操作前:
1.不明觉厉,手指点点就能把输入账号密码等一系列流程舍去就能登录成功。
2.好奇于其背后的实现逻辑。是否很复杂?很高大上?能否自己也照搬免登成功?
深入研究一番:
原来这么简单,把它想象成日常用账号密码登录即可,只不过前面多加一步,判断之前是否绑定过即可。绑定过,调登录认证服务的接口,没绑定过,提示“暂未绑定,无法免登”。
前期准备工作:
涉及到这种第三方对接请求之类的,肯定要去申请对应的appkey和appsecret,具体看实际项目,一般都有官网去申请。像本次浙政钉免登,就需要去钉钉官网申请服务上架。有详细说明,该准备什么材料就准备什么材料。一般让项目经理去做。开发只需拿到申请下来的账号和密码即可,要么还有一份技术对接文档。实际如下得到一个域名地址和应用账号密码(配置文件中加起)
dtalk:
domainname: XXXXXX
appkey: XXXXXX
appsecret: XXXXXX
从后台开发角度:
根据上面提到的步骤。无非就2个接口。一个首次登陆,绑定用户接口。另一个免登接口。
Ps:免登不仅仅是后台的活,前端也需要调这个申请上架后应用的一个authCode。放在接口请求参数中带过来。原因就是保证,根据authCode换取用户信息,免登过程中是项目里的免登操作,校验前后双方都是用的同一个上架的应用。也提高了这中间的安全性。
换取用户信息时需要用到访问应用的AccessToken,做成2个Service
public interface UserDtalkService extends IService {
/**
* 政务钉钉-获取AccessToken
*/
String getAccessToken();
/**
* 根据authCode换取用户信息
* @param authCode
*/
JSONObject getDingtalkAppUser(String authCode);
}
--------------------------IMPL实现类-----------------------------
getClient请求方法。
public PostClient getClient(String api){
ExecutableClient executableClient =ExecutableClient.getInstance();
executableClient.setAccessKey(appKey);
executableClient.setSecretKey(appSecret);
executableClient.setDomainName(domaiNname);
executableClient.setProtocal("https");
executableClient.init();
return executableClient.newPostClient(api);
}
创建一个缓存,用于存储accessToken,不存在则重新获取。
其中的appKey和appSecret是之前上架时提供的。
/**
* 政务端-浙政钉accessToken
*/
public final static String GOV_DTALK_ACCESS_TOKEN = "gov_dtalk_access_token";
@CreateCache(name = "govuser.accesstoken", cacheType = CacheType.REMOTE)
private Cache accessTokenCache;
public String getAccessToken() {
//判断缓存中accessToken是否存在
String redisAccessToken = accessTokenCache.get(GOV_DTALK_ACCESS_TOKEN);
if (StringUtils.isNotBlank(redisAccessToken)){
return redisAccessToken;
}
try {
//缓存中不存在,则重新获取
String api = "/gettoken.json";
PostClient client = this.getClient(api);
client.addParameter("appkey", appKey);
client.addParameter("appsecret", appSecret);
//调用API
String apiResult = client.post();
log.info("getAccessToken返回结果打印:"+apiResult);
JSONObject jsonObject = JSONObject.parseObject(apiResult);
if (jsonObject != null && jsonObject.getBoolean("success")){
JSONObject contentObj = jsonObject.getJSONObject("content");
JSONObject dataObj = contentObj.getJSONObject("data");
String accessToken = dataObj.getString("accessToken");
long expiresIn = dataObj.getLong("expiresIn");
accessTokenCache.put(GOV_DTALK_ACCESS_TOKEN, accessToken, expiresIn, TimeUnit.SECONDS);
return accessToken;
}
}catch (Exception e){
log.error("浙政钉-获取accessToken异常",e);
}
return null;
}
根据authCode换取用户信息。也会去调用getAccessToken()方法拿Token。
public JSONObject getDingtalkAppUser(String authCode) {
try {
String api ="/rpc/oauth2/dingtalk_app_user.json";
PostClient postClient = this.getClient(api);
postClient.addParameter("access_token", this.getAccessToken());
postClient.addParameter("auth_code", authCode);
String apiResult = postClient.post();
log.info("getDingtalkAppUser返回结果打印:"+apiResult);
JSONObject jsonObject = JSONObject.parseObject(apiResult);
if (jsonObject != null && jsonObject.getBoolean("success")){
JSONObject contentObj = jsonObject.getJSONObject("content");
JSONObject dataObj = contentObj.getJSONObject("data");
return dataObj;
}
}catch (Exception e){
log.error("浙政钉-根据authCode换取用户信息异常",e);
}
return null;
}
首次登陆,绑定用户接口:
创表user_dtalk绑定记录表:绑定钉钉用户和系统用户
CREATE TABLE `user_dtalk` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`dtalk_id` varchar(128) DEFAULT NULL COMMENT '钉钉账号id',
`bind_user_id` int(20) DEFAULT NULL COMMENT '系统用户账号id',
`dtalk_user_info` text COMMENT '钉钉用户信息',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`bind_user_name` varchar(255) DEFAULT NULL COMMENT 系统用户名',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
/**
* 浙政钉2.0-首次登陆,绑定用户
* @param bindDataForm
*/
@GetMapping("loginAndBind")
public Result loginAndBind(BindDataForm bindDataForm) {
return userDtalkService.doBindUser(bindDataForm);
}
bindDataForm入参中主要是userName、password、authCode。
系统用户名密码以及前端调钉钉获取的授权码。
具体Impl:
大致按照如下步骤实现
①拿钉钉唯一accountId
②校验当前用户账号和密码,进行绑定
③返回token
1) 拿参数里的authCode去换取用户信息(就是上面Service中getDingtalkAppUser方法)
2) 获取钉钉用户信息成功,能得到其钉钉唯一的accountId。
JSONObject userData = this.getDingtalkAppUser(bindDataForm.getAuthCode());
if (userData == null) {
return Result.fail("获取钉钉用户信息失败");
}
String accountId = userData.getString("accountId");
3) 新建一个UserDtalk对象,将上面得到的accountId和钉钉用户详情JSON进行赋值。
UserDtalk userDtalk = new UserDtalk();
userDtalk.setDtalkId(accountId);
userDtalk.setDtalkUserInfo(FastJsonUtil.toJSONString(userData))
4) 根据入参的userName和password判断进行匹配,是否存在当前用户及密码是否匹配。Ps:项目里是微服务,认证登录啥的用的都是标准版的。开了提供用户名访问用户信息的接口。
// 判断账号密码
if (StringUtil.isEmptyOrNull(bindDataForm.getPassword())) {
return Result.fail("密码不能为空");
}
if (StringUtil.isEmptyOrNull(bindDataForm.getUserName())) {
return Result.fail("账号不能为空");
}
// 获取账号信息
Result res = organizationProvider.getUserByUniqueId(bindDataForm.getUserName());
User userFromAdmin = res.getData();
if (userFromAdmin == null) {
return Result.fail("未查询到登陆用户");
}
// 判断密码是否正确
BCryptPasswordEncoder bcryptPasswordEncoder = new BCryptPasswordEncoder();
if (!bcryptPasswordEncoder.matches(bindDataForm.getPassword(),userFromAdmin.getPassword())) {
return Result.fail("账号密码错误");
}
5) 账号存在,密码也匹配,把当前登录用户一部分信息也赋值到userDtalk对象去。
userDtalk.setBindUserId(userFromAdmin.getId());
userDtalk.setBindUserName(userFromAdmin.getUsername());
userDtalk.setCreateTime(new Date());
6) 做进一步校验,防止之前已经绑定过,后期系统账号信息做了更改。没同部更新到绑定记录表中。Ps:系统用户的id和钉钉id相当于做了主键,唯一判断。存在,则更新最新信息。不存在走保存。
UserDtalk oldRecord = baseMapper.getByUserIdAndDtalkId(userFromAdmin.getId(), accountId);
if (Objects.nonNull(oldRecord)) {
oldRecord.setDtalkUserInfo(userDtalk.getDtalkUserInfo());
oldRecord.setBindUserName(userDtalk.getBindUserName());
baseMapper.updateById(oldRecord);
} else {
this.save(userDtalk);
}
7) 绑定数据入库之后,调用登录接口获取token。
Map token = tokenService.getTokenAndSaveToCache(bindDataForm);
if (null == token) {
return Result.fail("登录失败");
}
return Result.success(token);
8) getTokenAndSaveToCache中主要是远程调用authentication-server服务来获取登录接口。返回token给前端。外加存储token至缓存,提高下次免登速度。
public Map getTokenAndSaveToCache(BindDataForm bindDataForm) {
try {
// 远程调用登录接口
Map loginResultMap = authenticationProvider.login(bindDataForm.getUserName(), bindDataForm.getPassword(), "password", "read");
// 存储token至缓存
userTokenCache.put(REDIS_KEY_PRO + String.valueOf(loginResultMap.get("user_id")), FastJsonUtil.toJSONString(loginResultMap), Long.parseLong(String.valueOf(loginResultMap.get("expires_in"))), TimeUnit.SECONDS);
return loginResultMap;
} catch (Exception e) {
log.info("请求登录接口异常: username:{}", e, bindDataForm.getUserName());
return null;
}
}
至此首次登录-绑定用户这一步已经走通。
接下来就是
免登接口:
上面绑定接口时已经参数中带了账号和密码,在免登这里只需要一个authCode授权码即可。因为,有getDingtalkAppUser方法根据authCode换取用户信息。如果上次绑定成功过后,系统用户id和钉钉id将存在于user_dtalk表中。因此,免登操作只需要去user_dtalk表中查询accountId是否存在。存在即代表上次绑定成功。不存在即还未绑定用户,前端进行友好提示。
具体Impl:
大致按照如下步骤实现
①根据authCode访问getDingtalkAppUser服务。拿钉钉唯一accountId。
②查询绑定表dtalk_id钉钉id是否存在
③调远程接口返回token。Ps:authentication-server中还提供了根据用户名登录的接口。
1) 根据authCode拿accountId
JSONObject userData = this.getDingtalkAppUser(authCode);
if (userData == null) {
log.info("获取钉钉用户信息失败");
return Result.fail("获取钉钉用户信息失败");
}
String accountId = userData.getString("accountId");
2) 查看绑定表是否存在该accountId(钉钉id)
UserDtalk userDtalk = this.getOne(new QueryWrapper().eq("dtalk_id", accountId));
if (userDtalk == null) {
return Result.fail(SystemErrorType.DTALK_UNBIND,"暂未绑定用户,请先绑定");
}
3) 已绑定,远程调用生成token。
Map token = tokenService.getTokenFromCache(userDtalk);
4) 先判断缓存中是否还存在token。可能上一步刚绑定完,下一步退出进来尝试免登操作。
public Map getTokenFromCache(UserDtalk userDtalk) {
// 从缓存读取token
if (null != userTokenCache.get(REDIS_KEY_PRO + userDtalk.getBindUserId())) {
Map token = JsonUtil.toMapFromJsonStr(userTokenCache.get(REDIS_KEY_PRO + userDtalk.getBindUserId()));
return token;
}
}
5) 缓存中不存在,过期了可能。再来远程调用根据用户名就能返回token的接口。同样的,提高下次免登速度。存一下缓存。
public Map getTokenFromCache(UserDtalk userDtalk) {
try {
// 远程调用登录接口
Map loginResultMap = authenticationProvider.loginByMobile(userDtalk.getBindUserName(), null, "mobile", "read");
// 存储token至缓存
userTokenCache.put(REDIS_KEY_PRO + String.valueOf(loginResultMap.get("user_id")), FastJsonUtil.toJSONString(loginResultMap), Long.parseLong(String.valueOf(loginResultMap.get("expires_in"))), TimeUnit.SECONDS);
return loginResultMap;
} catch (Exception e) {
log.info("远程调用mobile类型登录接口异常: username={}", e, userDtalk.getBindUserName());
}
}
至此,免登over。又一个点Get到了。
想看前面几期文章 请点击下列图片
我和我的项目之沙箱环境模拟支付宝支付(附演示视频)
我和我的项目之对接浙江政务服务网(法人)登录
初遇ZooKeeper