在前面的设计和实现中,我们的微服务开发平台通过JustAuth来实现第三方授权登录,通过集成公共组件,着实减少了很多工作量,大多数的第三方登录直接通过配置就可以实现。而在第三方授权登录中,微信小程序授权登录和APP微信授权登录是两种特殊的第三方授权登录。
JustAuth之所以能够将多种第三方授权登录服务整合在一起,抽象公共组件的原因是大多数的授权登录服务器都是遵循OAuth2.0协议开发,虽然略有不同但可通过适配器进行转换为统一接口。微信小程序授权登录和APP的微信授权登录也是OAutn2.0协议的授权登录,但在对接的流程中不是完整的OAuth2.0对接流程。
通常的第三方授权登录过程中,获取token的state和code是在回调客户端url中获取的,而微信小程序授权登录和APP的微信授权登录获取token的state和code是使用微信提供的特定方法获取到的,然后通过微信传给客户端,客户端拿到code之后到后台取获取openid等微信用户信息。然后,再进行系统登录相关操作。
1、用户进入小程序。
2、小程序前端通过从缓存中获取是否有token来判定用户是否登录。
3、如果未登录,那么跳转到小程序登录页。
4、小程序前端执行微信登录方法wx.login获取微信登录的code(此时并未进行微信授权登录)。
5、小程序前端通过code向业务后台发送请求获取用户唯一的openid。
6、业务系统根据openid或者unionid判断该用户是否绑定了业务用户,并将是否绑定信息返回给前台。
7、如果没有绑定过,那么前端展示微信授权登录按钮。
8、用户点击“授权登录”按钮之后,小程序前端会获取到加密的用户信息。
9、小程序前端将加密的用户信息传到业务后台进行解密。
10、业务后台收到加密用户信息后,通过请求微信服务器解密用户信息,并将用户信息存储到业务系统表。
11、后台将解密后的用户信息(非私密信息)返回到小程序前台。
12、如果是没有绑定的,那么小程序前台弹出是否获取当前用户手机号的弹出框。
13、用户选择是否获取微信绑定的手机号来注册或绑定到业务系统的用户。
14、当用户点击统一获取手机号时,微信会返回加密后的手机号,然后前端将加密后的手机号发送到业务后台解密。
15、业务后台获取到手机号码之后,会根据手机号码在系统用户表中进行匹配,如果匹配到用户,那么直接返回小程序用户信息。
16、当用户不同意获取手机号时,那么小程序跳转到输入账号密码进行绑定页面。
17、当绑定操作执行成功之后,微信小程序调用第三方登录获取token方式,向业务后台获取token。
18、用户小程序授权登录、注册、绑定成功。
微信通过其开放平台提供小程序登录功能接口,我们的业务服务可以通过小程序的登录接口方便地获取微信提供的用户身份标识,进而将业务自身用户体系和微信用户相结合,从而更完美地在微信小程序中实现业务功能。
微信小程序提供了对接登录的SDK,我们只需要按照其官方文档对接开发即可。同时也有很多开源组件将SDK再次进行封装,在业务开发中可以更快速的集成小程序各个接口的调用。
出于快速开发的原则,同时也少走弯路、少踩坑,我们可以选择一款实现比较完善的组件进行微信小程序的对接。weixin-java-miniapp是集成微信小程序相关SDK操作的工具包,我们在项目中集成此工具包来实现微信小程序授权登录。
一般在选择开源工具包时,我们不会选择最新版,而是选择稳定版本,但是微信的开放接口经常变动,这里为了能够兼容最新的微信小程序接口,我们在引用包的时候一定要选择更新版本,否则会影响部分接口的调用。
............ ......4.4.0 ...... com.github.binarywang weixin-java-miniapp${weixin-java-miniapp.version}
关于小程序如何注册,appid和appsecret如何获取,这里不展开讲,微信开放平台有详细的说明文档。
wx: miniapp: configs: - appid: #微信小程序appid secret: #微信小程序secret token: #微信小程序消息服务器配置的token aesKey: #微信小程序消息服务器配置的EncodingAESKey msgDataFormat: JSON
...... @Data @ConfigurationProperties(prefix = "wx.miniapp") public class WxMaProperties { private Listconfigs; @Data public static class Config { /** * 设置微信小程序的appid */ private String appid; /** * 设置微信小程序的Secret */ private String secret; /** * 设置微信小程序消息服务器配置的token */ private String token; /** * 设置微信小程序消息服务器配置的EncodingAESKey */ private String aesKey; /** * 消息格式,XML或者JSON */ private String msgDataFormat; } } ......
...... private final WxMaProperties properties; @Autowired public WxMaConfiguration(WxMaProperties properties) { this.properties = properties; } @Bean public WxMaService wxMaService() { Listconfigs = this.properties.getConfigs(); if (configs == null) { throw new WxRuntimeException("配置错误!"); } WxMaService maService = new WxMaServiceImpl(); maService.setMultiConfigs( configs.stream() .map(a -> { WxMaDefaultConfigImpl config = new WxMaDefaultConfigImpl(); config.setAppid(a.getAppid()); config.setSecret(a.getSecret()); config.setToken(a.getToken()); config.setAesKey(a.getAesKey()); config.setMsgDataFormat(a.getMsgDataFormat()); return config; }).collect(Collectors.toMap(WxMaDefaultConfigImpl::getAppid, a -> a, (o, n) -> o))); return maService; } ......
/** * 登陆接口 */ @ApiOperation(value = "小程序登录接口") @ApiImplicitParams({ @ApiImplicitParam(name = "code", value = "小程序code", dataType="String", paramType = "query"), }) @GetMapping("/login") public Result> login(@PathVariable String appid, String code) { if (StringUtils.isBlank(code)) { return Result.error("code 不能为空"); } if (!wxMaService.switchover(appid)) { throw new IllegalArgumentException(String.format("未找到对应appid=[%s]的配置,请核实!", appid)); } WeChatMiniAppLoginDTO weChatMiniAppLoginDTO = new WeChatMiniAppLoginDTO(); try { WxMaJscode2SessionResult session = wxMaService.getUserService().getSessionInfo(code); weChatMiniAppLoginDTO.setOpenid(session.getOpenid()); weChatMiniAppLoginDTO.setUnionid(session.getUnionid()); // 通过openId获取在系统中是否是已经绑定过的用户,如果没有绑定,那么返回到前台,提示需要绑定或者注册用户 LambdaQueryWrappersocialLambdaQueryWrapper = new LambdaQueryWrapper<>(); // 如果微信开通了开放平台,那么各个渠道(小程序、公众号等)都会有统一的unionid,如果没开通,就仅仅使用openId if (StringUtils.isBlank(session.getUnionid())) { socialLambdaQueryWrapper.eq(JustAuthSocial::getOpenId, session.getOpenid()) .eq(JustAuthSocial::getSource, "WECHAT_MINI_APP"); } else { socialLambdaQueryWrapper.eq(JustAuthSocial::getUnionId, session.getUnionid()) .and(e -> e.eq(JustAuthSocial::getSource, "WECHAT_MINI_APP") .or().eq(JustAuthSocial::getSource, "WECHAT_OPEN") .or().eq(JustAuthSocial::getSource, "WECHAT_MP") .or().eq(JustAuthSocial::getSource, "WECHAT_ENTERPRISE") .or().eq(JustAuthSocial::getSource, "WECHAT_APP")); } JustAuthSocial justAuthSocial = justAuthSocialService.getOne(socialLambdaQueryWrapper, false); if (null == justAuthSocial) { weChatMiniAppLoginDTO.setUserInfoAlready(false); weChatMiniAppLoginDTO.setUserBindAlready(false); justAuthSocial = new JustAuthSocial(); justAuthSocial.setAccessCode(session.getSessionKey()); justAuthSocial.setOpenId(session.getOpenid()); justAuthSocial.setUnionId(session.getUnionid()); justAuthSocial.setSource("WECHAT_MINI_APP"); justAuthSocialService.save(justAuthSocial); } else { justAuthSocial.setAccessCode(session.getSessionKey()); justAuthSocialService.updateById(justAuthSocial); } // 将socialId进行加密返回,用于前端进行第三方登录,获取token DES des = new DES(Mode.CTS, Padding.PKCS5Padding, secretKey.getBytes(), secretKeySalt.getBytes()); // 这里将source+uuid通过des加密作为key返回到前台 String socialKey = "WECHAT_MINI_APP" + StrPool.UNDERLINE + (StringUtils.isBlank(session.getUnionid()) ? session.getOpenid() : session.getUnionid()); // 将socialKey放入缓存,默认有效期2个小时,如果2个小时未完成验证,那么操作失效,重新获取,在system:socialLoginExpiration配置 redisTemplate.opsForValue().set(AuthConstant.SOCIAL_VALIDATION_PREFIX + socialKey, String.valueOf(justAuthSocial.getId()), socialLoginExpiration, TimeUnit.SECONDS); String desSocialKey = des.encryptHex(socialKey); weChatMiniAppLoginDTO.setBindKey(desSocialKey); // 查询是否绑定用户 // 判断此第三方用户是否被绑定到系统用户 Result
当微信小程序前端获取到用户授权可以获取用户信息时,微信小程序前端将加密的用户信息发送到业务后台,业务后台请求微信服务器将用户信息解密并保存到我们的第三方用户登录表内。
/** * 获取用户信息接口 */ @ApiOperation(value = "小程序获取用户信息接口") @ApiImplicitParams({ @ApiImplicitParam(name = "socialKey", value = "加密的登录key,用于绑定用户", required = true, dataType="String", paramType = "query"), @ApiImplicitParam(name = "signature", value = "使用 sha1( rawData + sessionkey ) 得到字符串,用于校验用户信息", required = true, dataType="String", paramType = "query"), @ApiImplicitParam(name = "rawData", value = "不包括敏感信息的原始数据字符串,用于计算签名", required = true, dataType="String", paramType = "query"), @ApiImplicitParam(name = "encryptedData", value = "包括敏感数据在内的完整用户信息的加密数据", required = true, dataType="String", paramType = "query"), @ApiImplicitParam(name = "iv", value = "加密算法的初始向量", required = true, dataType="String", paramType = "query") }) @GetMapping("/info") public Result> info(@PathVariable String appid, String socialKey, String signature, String rawData, String encryptedData, String iv) { if (!wxMaService.switchover(appid)) { throw new IllegalArgumentException(String.format("未找到对应appid=[%s]的配置,请核实!", appid)); } // 查询第三方用户信息 JustAuthSocial justAuthSocial = this.getJustAuthSocial(socialKey); if (StringUtils.isBlank(justAuthSocial.getAccessCode())) { throw new BusinessException("登录状态失效,请尝试重新进入小程序"); } // 用户信息校验 if (!wxMaService.getUserService().checkUserInfo(justAuthSocial.getAccessCode(), rawData, signature)) { WxMaConfigHolder.remove();//清理ThreadLocal return Result.error("user check failed"); } // 解密用户信息 WxMaUserInfo userInfo = wxMaService.getUserService().getUserInfo(justAuthSocial.getAccessCode(), encryptedData, iv); WxMaConfigHolder.remove();//清理ThreadLocal justAuthSocial.setAvatar(userInfo.getAvatarUrl()); justAuthSocial.setUnionId(userInfo.getUnionId()); justAuthSocial.setNickname(userInfo.getNickName()); justAuthSocialService.updateById(justAuthSocial); return Result.data(userInfo); }
/** * 获取用户绑定手机号信息 */ @ApiOperation(value = "小程序获取用户绑定手机号信息") @ApiImplicitParams({ @ApiImplicitParam(name = "socialKey", value = "加密的登录key,用于绑定用户", required = true, dataType="String", paramType = "query"), @ApiImplicitParam(name = "encryptedData", value = "包括敏感数据在内的完整用户信息的加密数据", required = true, dataType="String", paramType = "query"), @ApiImplicitParam(name = "iv", value = "加密算法的初始向量", required = true, dataType="String", paramType = "query") }) @GetMapping("/phone") public Result> phone(@PathVariable String appid, String socialKey, String encryptedData, String iv) { if (!wxMaService.switchover(appid)) { throw new IllegalArgumentException(String.format("未找到对应appid=[%s]的配置,请核实!", appid)); } // 查询第三方用户信息 JustAuthSocial justAuthSocial = this.getJustAuthSocial(socialKey); if (StringUtils.isBlank(justAuthSocial.getAccessCode())) { throw new BusinessException("登录状态失效,请尝试重新进入小程序"); } // 解密 WxMaPhoneNumberInfo phoneNoInfo = wxMaService.getUserService().getPhoneNoInfo(justAuthSocial.getAccessCode(), encryptedData, iv); WxMaConfigHolder.remove();//清理ThreadLocal // 不带区号的手机,国外的手机会带区号 String phoneNumber = phoneNoInfo.getPurePhoneNumber(); // 查询用户是否存在,如果存在,那么直接调用绑定接口 LambdaQueryWrapperlambdaQueryWrapper = new LambdaQueryWrapper<>(); lambdaQueryWrapper.eq(User::getMobile, phoneNumber); User userInfo = userService.getOne(lambdaQueryWrapper); Long userId; // 判断返回信息 if (null != userInfo && null != userInfo.getId()) { userId = userInfo.getId(); } else { // 如果用户不存在,那么调用新建用户接口,并绑定 CreateUserDTO createUserDTO = new CreateUserDTO(); createUserDTO.setAccount(phoneNumber); createUserDTO.setMobile(phoneNumber); createUserDTO.setNickname(StringUtils.isBlank(justAuthSocial.getNickname()) ? phoneNumber : justAuthSocial.getNickname()); createUserDTO.setPassword(StringUtils.isBlank(justAuthSocial.getUnionId()) ? justAuthSocial.getOpenId() : justAuthSocial.getUnionId()); createUserDTO.setStatus(GitEggConstant.UserStatus.ENABLE); createUserDTO.setAvatar(justAuthSocial.getAvatar()); createUserDTO.setEmail(justAuthSocial.getEmail()); createUserDTO.setStreet(justAuthSocial.getLocation()); createUserDTO.setComments(justAuthSocial.getRemark()); CreateUserDTO resultUserAdd = userService.createUser(createUserDTO); if (null != resultUserAdd && null != resultUserAdd.getId()) { userId = resultUserAdd.getId(); } else { // 如果添加失败,则返回失败信息 return Result.data(resultUserAdd); } } // 执行绑定操作 justAuthService.userBind(justAuthSocial.getId(), userId); return Result.success("账号绑定成功"); }
/** * 绑定当前登录账号 */ @ApiOperation(value = "绑定当前登录账号") @ApiImplicitParams({ @ApiImplicitParam(name = "socialKey", value = "加密的登录key,用于绑定用户", required = true, dataType="String", paramType = "query") }) @GetMapping("/bind") public Result> bind(@PathVariable String appid, @NotBlank String socialKey, @CurrentUser GitEggUser user) { if (!wxMaService.switchover(appid)) { throw new IllegalArgumentException(String.format("未找到对应appid=[%s]的配置,请核实!", appid)); } if (null == user || (null != user && null == user.getId())) { throw new BusinessException("用户未登录"); } // 查询第三方用户信息 JustAuthSocial justAuthSocial = this.getJustAuthSocial(socialKey); if (StringUtils.isBlank(justAuthSocial.getAccessCode())) { throw new BusinessException("账号绑定失败,请尝试重新进入小程序"); } // 执行绑定操作 justAuthService.userBind(justAuthSocial.getId(), user.getId()); return Result.success("账号绑定成功"); }
/** * 解绑当前登录账号 */ @ApiOperation(value = "解绑当前登录账号") @GetMapping("/unbind") public Result> unbind(@PathVariable String appid, @CurrentUser GitEggUser user) { if (!wxMaService.switchover(appid)) { throw new IllegalArgumentException(String.format("未找到对应appid=[%s]的配置,请核实!", appid)); } if (null == user || (null != user && null == user.getId())) { throw new BusinessException("用户未登录"); } LambdaQueryWrapperqueryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(JustAuthSocialUser::getUserId, user.getId()); justAuthSocialUserService.remove(queryWrapper); return Result.success("账号解绑成功"); }
通过以上接口的功能,基本实现了微信小程序前端进行绑定、注册以及获取用户信息、用户手机号所需要的接口,下面来实现小程序前端具体的业务实现。
微信小程序前端开发有多种方式,可以使用微信小程序官方开发方式,也可以使用第三方的开发方式。因为大多数前端都会使用Vue.js开发,而mpvue可以使用开发Vue.js的方式来开发微信小程序,所以这里我们选择使用mpvue来开发微信小程序。这里不详细讲解mpvue框架的搭建过程,只详细说明微信小程序授权登录相关功能,有需要的可以参考mpvue官方文档。
因为我们的开发框架是支持多租户的,同时也是支持多个小程序的,为了同一套后台可以支持多个微信小程序,这里选择在发布的微信小程序中配置appId,由微信小程序前端参数来确定具体的微信小程序。
import fly from '@/utils/requestWx' // 获取用户信息 export function getOpenId (params) { return fly.get(`/wx/user/${params.appId}/login`, params) } // 获取用户信息 export function getUserInfo (params) { return fly.get(`/wx/user/${params.appId}/info`, params) } // 获取用户手机号 export function getUserPhone (params) { return fly.get(`/wx/user/${params.appId}/phone`, params) } // 绑定微信账号 export function bindWeChatUser (params) { return fly.get(`/wx/user/${params.appId}/bind`, params) } // 解绑微信账号 export function unbindWeChatUser (params) { return fly.get(`/wx/user/${params.appId}/unbind`) } // 登录 export function postToken (params) { return fly.post(`/oauth/token`, params) } // 退出登录 export function logout () { return fly.post(`/oauth/logout`) } // 获取登录用户信息 export function getLoginUserInfo () { return fly.get(`/system/account/user/info`) }
微信授权登录
wxLogin () { var that = this wx.login({ success (res) { that.code = res.code const params = { appId: appId, code: res.code } getOpenId(params).then(res => { if (res.code === 200 && res.data) { const result = res.data mpvue.setStorageSync('openid', result.openid) mpvue.setStorageSync('unionid', result.unionid) mpvue.setStorageSync('bindKey', result.bindKey) mpvue.setStorageSync('userBindAlready', result.userBindAlready) // 1、如果绑定过,那么直接使用绑定用户登录 // 2、如果没有绑定过,那弹出获取用户信息和获取手机号信息进行绑定 if (result.userBindAlready) { const loginParams = { grant_type: 'social', social_key: mpvue.getStorageSync('bindKey') } postToken(loginParams).then(res => { if (res.code === 200) { console.log(res) const data = res.data mpvue.setStorageSync('token', data.token) mpvue.setStorageSync('refreshToken', data.refreshToken) // 获取用户信息 that.loginSuccess() } else { Toast(res.msg) } }) } } else { Toast(res.msg) } }) } }) },
bindGetUserInfo: function (res) { var that = this if (res.mp.detail.errMsg === 'getUserInfo:ok') { const userParams = { appId: appId, socialKey: mpvue.getStorageSync('bindKey'), signature: res.mp.detail.signature, rawData: res.mp.detail.rawData, encryptedData: res.mp.detail.encryptedData, iv: res.mp.detail.iv } getUserInfo(userParams).then(response => { const userBindAlready = mpvue.getStorageSync('userBindAlready') // 1、如果绑定过,那么直接使用绑定用户登录 // 2、如果没有绑定过,那弹出获取用户信息和获取手机号信息进行绑定 if (userBindAlready) { const loginParams = { grant_type: 'social', social_key: mpvue.getStorageSync('bindKey') } postToken(loginParams).then(res => { if (res.code === 200) { console.log(res) const data = res.data mpvue.setStorageSync('token', data.token) mpvue.setStorageSync('refreshToken', data.refreshToken) // 获取用户信息 that.loginSuccess() } else { // 弹出获取手机号授权按钮 that.showUserPhoneVisible = true } }) } else { // 弹出获取手机号授权按钮 that.showUserPhoneVisible = true } }) } else { console.log('点击了拒绝') } },
bindGetUserPhone (e) { const that = this if (e.mp.detail.errMsg === 'getPhoneNumber:ok') { console.log(e.mp.detail) // 写入store const params = { appId: appId, socialKey: mpvue.getStorageSync('bindKey'), encryptedData: e.mp.detail.encryptedData, iv: e.mp.detail.iv } getUserPhone(params).then(res => { if (res.code === 200) { console.log(res) const loginParams = { grant_type: 'social', social_key: mpvue.getStorageSync('bindKey') } postToken(loginParams).then(res => { if (res.code === 200) { console.log(res) const data = res.data mpvue.setStorageSync('token', data.token) mpvue.setStorageSync('refreshToken', data.refreshToken) // 获取用户信息 that.loginSuccess() } else { } }) } else { that.showUserPhoneVisible = false // 获取用户信息 that.loginSuccess() Toast(res.msg) } }) } else { that.showUserPhoneVisible = false Toast('当前拒绝授权手机号登陆,请使用账号密码登录') } },
通过以上开发基本实现了微信小程序授权登录第三方业务系统的功能,在此基础上,注册的功能可以根据业务需求来扩展,大多数互联网业务,都会是微信小程序授权登录之后就自动注册用户。但是有些传统行业的业务,比如只有某些公司或组织内部的用户才能登录,那么是不允许微信授权登录就自助注册成系统用户的。微信小程序前端框架也可以归根据自己的需求,及擅长的开发方式来选择,但是微信授权登录的流程是不变的,可以在此基础上根据业务需求修改优化。
Gitee: GitEgg: GitEgg 是一款开源免费的企业级微服务应用开发框架,旨在整合目前主流稳定的开源技术框架,集成常用的最佳项目解决方案,实现可直接使用的微服务快速开发框架。
GitHub: GitHub - wmz1930/GitEgg: GitEgg 是一款开源免费的企业级微服务应用开发框架,旨在整合目前主流稳定的开源技术框架,集成常用的最佳项目解决方案,实现可直接使用的微服务快速开发框架。