在前面的設計和實現中,我們的微服務開發平臺通過JustAuth來實現第三方授權登入,通過整合公共元件,著實減少了很多工作量,大多數的第三方登入直接通過設定就可以實現。而在第三方授權登入中,微信小程式授權登入和APP微信授權登入是兩種特殊的第三方授權登入。
JustAuth之所以能夠將多種第三方授權登入服務整合在一起,抽象公共元件的原因是大多數的授權登入伺服器都是遵循OAuth2.0協定開發,雖然略有不同但可通過介面卡進行轉換為統一介面。微信小程式授權登入和APP的微信授權登入也是OAutn2.0協定的授權登入,但在對接的流程中不是完整的OAuth2.0對接流程。
通常的第三方授權登入過程中,獲取token的state和code是在回撥使用者端url中獲取的,而微信小程式授權登入和APP的微信授權登入獲取token的state和code是使用微信提供的特定方法獲取到的,然後通過微信傳給使用者端,使用者端拿到code之後到後臺取獲取openid等微信使用者資訊。然後,再進行系統登入相關操作。
微信通過其開放平臺提供小程式登入功能介面,我們的業務服務可以通過小程式的登入介面方便地獲取微信提供的使用者身份標識,進而將業務自身使用者體系和微信使用者相結合,從而更完美地在微信小程式中實現業務功能。
微信小程式提供了對接登入的SDK,我們只需要按照其官方檔案對接開發即可。同時也有很多開源元件將SDK再次進行封裝,在業務開發中可以更快速的整合小程式各個介面的呼叫。
出於快速開發的原則,同時也少走彎路、少踩坑,我們可以選擇一款實現比較完善的元件進行微信小程式的對接。weixin-java-miniapp是整合微信小程式相關SDK操作的工具包,我們在專案中整合此工具包來實現微信小程式授權登入。
一般在選擇開源工具包時,我們不會選擇最新版,而是選擇穩定版本,但是微信的開放介面經常變動,這裡為了能夠相容最新的微信小程式介面,我們在參照包的時候一定要選擇更新版本,否則會影響部分介面的呼叫。
......
<properties>
......
<!-- 微信小程式版本號 -->
<weixin-java-miniapp.version>4.4.0</weixin-java-miniapp.version>
</properties>
......
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>weixin-java-miniapp</artifactId>
<version>${weixin-java-miniapp.version}</version>
</dependency>
......
關於小程式如何註冊,appid和appsecret如何獲取,這裡不展開講,微信開放平臺有詳細的說明檔案。
wx:
miniapp:
configs:
- appid: #微信小程式appid
secret: #微信小程式secret
token: #微信小程式訊息伺服器設定的token
aesKey: #微信小程式訊息伺服器設定的EncodingAESKey
msgDataFormat: JSON
......
@Data
@ConfigurationProperties(prefix = "wx.miniapp")
public class WxMaProperties {
private List<Config> configs;
@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() {
List<WxMaProperties.Config> configs = 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獲取在系統中是否是已經繫結過的使用者,如果沒有繫結,那麼返回到前臺,提示需要繫結或者註冊使用者
LambdaQueryWrapper<JustAuthSocial> socialLambdaQueryWrapper = 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<Object> bindResult = justAuthService.userBindQuery(justAuthSocial.getId());
if(null != bindResult && null != bindResult.getData() && bindResult.isSuccess())
{
weChatMiniAppLoginDTO.setUserInfoAlready(true);
weChatMiniAppLoginDTO.setUserBindAlready(true);
} else {
// 這裡需要處理返回訊息,前端需要根據返回是否已經繫結好的訊息來判斷
weChatMiniAppLoginDTO.setUserInfoAlready(false);
weChatMiniAppLoginDTO.setUserBindAlready(false);
}
return Result.data(weChatMiniAppLoginDTO);
} catch (WxErrorException e) {
log.error(e.getMessage(), e);
return Result.error("小程式登入失敗:" + e);
} finally {
WxMaConfigHolder.remove();//清理ThreadLocal
}
}
/**
* 獲取使用者資訊介面
*/
@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();
// 查詢使用者是否存在,如果存在,那麼直接呼叫繫結介面
LambdaQueryWrapper<User> lambdaQueryWrapper = 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("使用者未登入");
}
LambdaQueryWrapper<JustAuthSocialUser> queryWrapper = 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`)
}
<div class="login-btn" v-show="!showAccountLogin">
<van-button color="#1aad19" block open-type="getUserInfo" @getuserinfo="bindGetUserInfo" >微信授權登入</van-button>
</div>
<van-dialog
title="授權驗證"
@getphonenumber="bindGetUserPhone"
confirm-button-open-type="getPhoneNumber"
message="微信一鍵登入需要繫結您的手機號"
:show="showUserPhoneVisible">
</van-dialog>
<van-dialog
title="賬號繫結"
@getuserinfo="bindUserInfo"
confirm-button-open-type="getUserInfo"
message="登入成功,是否將賬號繫結到當前微信?"
:show="showUserInfoVisible">
</van-dialog>
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: https://gitee.com/wmz1930/GitEgg
GitHub: https://github.com/wmz1930/GitEgg