最近,公司在做小程序,提到小程序,那就繞不開小程序的登錄,實話實說,小程序的文檔寫的真的不咋地,這里記錄下自己做小程序登錄時寫的代碼以及遇到的一些問題(具體的業(yè)務需求我會屏蔽掉),這里只涉及到后端代碼,所以默認你是申請了一個小程序并且有了appid和secret。不管怎么說,還是放上官方的文檔鏈接:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html
官方時序圖及簡單說明

上圖要仔細的看一遍,這里簡單的做一個說明,首先由小程序端調用wx.login()去獲取code,然后,再通過wx.getUserInfo()去獲取用戶信息(這里請求login和getUserInfo是一起的,把這兩次請求的數(shù)據(jù)合并發(fā)給服務端的login接口),通過請求,把:
1.code //臨時登入憑證
// 如果不同意獲取用戶信息,則下面四個參數(shù)獲取不到
2.rawData //用戶非敏感信息,頭像和昵稱之類的
3.signature //簽名
4.encryteDate //用戶敏感信息,需要解密,(包含unionID)
5.iv //解密算法的向量
給到服務端,服務端根據(jù)code+appid+appsecret去請求:
方法,獲取到session_key和openid(這里無法獲取unionID),通過session_key,iv來解密encrypteDate獲取用戶敏感信息和unionID,把用戶信息保存到數(shù)據(jù)庫。然后,我們把sesssoin_key和openid保存下來,與token(自定義登入狀態(tài))來進行關聯(lián),最后把小程序需要的數(shù)據(jù)返回給小程序端,以后就通過token來維護用戶登入狀態(tài)。
用戶表結構設計
CREATE TABLE `wechat_user` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`token` varchar(100) NOT NULL COMMENT 'token',
`nickname` varchar(100) DEFAULT NULL COMMENT '用戶昵稱',
`avatar_url` varchar(500) DEFAULT NULL COMMENT '用戶頭像',
`gender` int(11) DEFAULT NULL COMMENT '性別 0-未知、1-男性、2-女性',
`country` varchar(100) DEFAULT NULL COMMENT '所在國家',
`province` varchar(100) DEFAULT NULL COMMENT '省份',
`city` varchar(100) DEFAULT NULL COMMENT '城市',
`mobile` varchar(100) DEFAULT NULL COMMENT '手機號碼',
`open_id` varchar(100) NOT NULL COMMENT '小程序openId',
`union_id` varchar(100) DEFAULT '' COMMENT '小程序unionId',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '插入時間',
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
`deleted_at` timestamp NULL DEFAULT NULL COMMENT '刪除時間',
PRIMARY KEY (`id`),
KEY `idx_open_id` (`open_id`),
KEY `idx_union_id` (`union_id`),
KEY `idx_mobile` (`mobile`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='小程序用戶表';
具體代碼
說明,如果@Getter報錯,那就刪掉,自己加Getter,Setter,@Api開頭的注解是swagger的注解,不需要的可以刪掉
請求類
@ApiModel
@Getter
@Setter
public class WechatLoginRequest {
@NotNull(message = "code不能為空")
@ApiModelProperty(value = "微信code", required = true)
private String code;
@ApiModelProperty(value = "用戶非敏感字段")
private String rawData;
@ApiModelProperty(value = "簽名")
private String signature;
@ApiModelProperty(value = "用戶敏感字段")
private String encryptedData;
@ApiModelProperty(value = "解密向量")
private String iv;
}
非敏感信息DO
@Getter
@Setter
public class RawDataDO {
private String nickName;
private String avatarUrl;
private Integer gender;
private String city;
private String country;
private String province;
}
用戶DO
@Getter
@Setter
public class WechatUserDO {
private Integer id;
private String token;
private String nickname;
private String avatarUrl;
private Integer gender;
private String country;
private String province;
private String city;
private String mobile;
private String openId;
private String unionId;
private String createdAt;
private String updatedAt;
}
HttpClientUtils
public class HttpClientUtils {
final static int TIMEOUT = 1000;
final static int TIMEOUT_MSEC = 5 * 1000;
public static String doPost(String url, Map<String, String> paramMap) throws IOException {
// 創(chuàng)建Httpclient對象
CloseableHttpClient httpClient = HttpClients.createDefault();
CloseableHttpResponse response = null;
String resultString = "";
try {
// 創(chuàng)建Http Post請求
HttpPost httpPost = new HttpPost(url);
// 創(chuàng)建參數(shù)列表
if (paramMap != null) {
List<NameValuePair> paramList = new ArrayList<>();
for (Entry<String, String> param : paramMap.entrySet()) {
paramList.add(new BasicNameValuePair(param.getKey(), param.getValue()));
}
// 模擬表單
UrlEncodedFormEntity entity = new UrlEncodedFormEntity(paramList);
httpPost.setEntity(entity);
}
httpPost.setConfig(builderRequestConfig());
// 執(zhí)行http請求
response = httpClient.execute(httpPost);
resultString = EntityUtils.toString(response.getEntity(), "UTF-8");
} catch (Exception e) {
throw e;
} finally {
try {
response.close();
} catch (IOException e) {
throw e;
}
}
return resultString;
}
private static RequestConfig builderRequestConfig() {
return RequestConfig.custom()
.setConnectTimeout(TIMEOUT_MSEC)
.setConnectionRequestTimeout(TIMEOUT_MSEC)
.setSocketTimeout(TIMEOUT_MSEC).build();
}
}
service
public interface WechatService {
Map<String, Object> getUserInfoMap(WechatLoginRequest loginRequest) throws Exception;
}
Service impl
@Service
public class WechatServiceImpl implements WechatService {
private static final String REQUEST_URL = "https://api.weixin.qq.com/sns/jscode2session";
private static final String = "authorization_code";
@Override
public Map<String, Object> getUserInfoMap(WechatLoginRequest loginRequest) throws Exception {
Map<String, Object> userInfoMap = new HashMap<>();
// logger報錯的話,刪掉就好,或者替換為自己的日志對象
logger.info("Start get SessionKey,loginRequest的數(shù)據(jù)為:" + JSONObject.toJSONString(loginRequest));
JSONObject sessionKeyOpenId = getSessionKeyOrOpenId(loginRequest.getCode());
// 這里的ErrorCodeEnum是自定義錯誤字段,可以刪除,用自己的方式處理
Assert.isTrue(sessionKeyOpenId != null, ErrorCodeEnum.P01.getCode());
// 獲取openId && sessionKey
String openId = sessionKeyOpenId.getString("openid");
// 這里的ErrorCodeEnum是自定義錯誤字段,可以刪除,用自己的方式處理
Assert.isTrue(openId != null, ErrorCodeEnum.P01.getCode());
String sessionKey = sessionKeyOpenId.getString("session_key");
WechatUserDO insertOrUpdateDO = buildWechatUserDO(loginRequest, sessionKey, openId);
// 根據(jù)code保存openId和sessionKey
JSONObject sessionObj = new JSONObject();
sessionObj.put("openId", openId);
sessionObj.put("sessionKey", sessionKey);
// 這里的set方法,自行導入自己項目的Redis,key自行替換,這里10表示10天
stringJedisClientTem.set(WechatRedisPrefixConstant.USER_OPPEN_ID_AND_SESSION_KEY_PREFIX + loginRequest.getCode(),
sessionObj.toJSONString(), 10, TimeUnit.DAYS);
// 根據(jù)openid查詢用戶,這里的查詢service自己寫,就不貼出來了
WechatUserDO user = wechatUserService.getByOpenId(openId);
if (user == null) {
// 用戶不存在,insert用戶,這里加了個分布式鎖,防止insert重復用戶,看自己的業(yè)務,決定要不要這段代碼
if (setLock(WechatRedisPrefixConstant.INSERT_USER_DISTRIBUTED_LOCK_PREFIX + openId, "1", 10)) {
// 用戶入庫,service自己寫
insertOrUpdateDO.setToken(getToken())
wechatUserService.save(insertOrUpdateDO);
userInfoMap.put("token", insertOrUpdateDO.getToken())
}
} else {
userInfoMap.put("token", wechatUser.getToken());
// 已存在,做已存在的處理,如更新用戶的頭像,昵稱等,根據(jù)openID更新,這里代碼自己寫
wechatUserService.updateByOpenId(insertOrUpdateDO);
}
return userInfoMap;
}
// 這里的JSONObject是阿里的fastjson,自行maven導入
private JSONObject getSessionKeyOrOpenId(String code) throws Exception {
Map<String, String> requestUrlParam = new HashMap<>();
// 小程序appId,自己補充
requestUrlParam.put("appid", APPID);
// 小程序secret,自己補充
requestUrlParam.put("secret", SECRET);
// 小程序端返回的code
requestUrlParam.put("js_code", code);
// 默認參數(shù)
requestUrlParam.put("grant_type", GRANT_TYPE);
// 發(fā)送post請求讀取調用微信接口獲取openid用戶唯一標識
String result = HttpClientUtils.doPost(REQUEST_URL, requestUrlParam);
return JSON.parseObject(result);
}
private WechatUserDO buildWechatUserAuthInfoDO(WechatLoginRequest loginRequest, String sessionKey, String openId){
WechatUserDO wechatUserDO = new WechatUserDO();
wechatUserDO.setOpenId(openId);
if (loginRequest.getRawData() != null) {
RawDataDO rawDataDO = JSON.parseObject(loginRequest.getRawData(), RawDataDO.class);
wechatUserDO.setNickname(rawDataDO.getNickName());
wechatUserDO.setAvatarUrl(rawDataDO.getAvatarUrl());
wechatUserDO.setGender(rawDataDO.getGender());
wechatUserDO.setCity(rawDataDO.getCity());
wechatUserDO.setCountry(rawDataDO.getCountry());
wechatUserDO.setProvince(rawDataDO.getProvince());
}
// 解密加密信息,獲取unionID
if (loginRequest.getEncryptedData() != null){
JSONObject encryptedData = getEncryptedData(loginRequest.getEncryptedData(), sessionKey, loginRequest.getIv());
if (encryptedData != null){
String unionId = encryptedData.getString("unionId");
wechatUserDO.setUnionId(unionId);
}
}
return wechatUserDO;
}
private JSONObject getEncryptedData(String encryptedData, String sessionkey, String iv) {
// 被加密的數(shù)據(jù)
byte[] dataByte = Base64.decode(encryptedData);
// 加密秘鑰
byte[] keyByte = Base64.decode(sessionkey);
// 偏移量
byte[] ivByte = Base64.decode(iv);
try {
// 如果密鑰不足16位,那么就補足.這個if中的內容很重要
int base = 16;
if (keyByte.length % base != 0) {
int groups = keyByte.length / base + 1;
byte[] temp = new byte[groups * base];
Arrays.fill(temp, (byte) 0);
System.arraycopy(keyByte, 0, temp, 0, keyByte.length);
keyByte = temp;
}
// 初始化
Security.addProvider(new BouncyCastleProvider());
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding", "BC");
SecretKeySpec spec = new SecretKeySpec(keyByte, "AES");
AlgorithmParameters parameters = AlgorithmParameters.getInstance("AES");
parameters.init(new IvParameterSpec(ivByte));
cipher.init(Cipher.DECRYPT_MODE, spec, parameters);// 初始化
byte[] resultByte = cipher.doFinal(dataByte);
if (null != resultByte && resultByte.length > 0) {
String result = new String(resultByte, "UTF-8");
return JSONObject.parseObject(result);
}
} catch (Exception e) {
logger.error("解密加密信息報錯", e.getMessage());
}
return null;
}
private boolean setLock(String key, String value, long expire) throws Exception {
boolean result = stringJedisClientTem.setNx(key, value, expire, TimeUnit.SECONDS);
return result;
}
private String getToken() throws Exception {
// 這里自定義token生成策略,可以用UUID+sale進行MD5
return "";
}
}
Controller
@RestController("LoginController")
@RequestMapping(value = "/wechat/login")
public class LoginController {
@Resource
WechatService wechatService;
@ApiOperation(value = "1.登入接口", httpMethod = "POST")
@PostMapping("/save")
public Map<String, Object> login(
@Validated @RequestBody WechatLoginRequest loginRequest) throws Exception {
Map<String, Object> userInfoMap = wechatService.getUserInfoMap(loginRequest);
return userInfoMap;
}
}
寫在最后
一些注意事項:
- code是有時效行的,5分鐘內有效,并且只能使用一次
- token的實現(xiàn),以及token過期時間,token放在數(shù)據(jù)庫中還是緩存中,token是否每次登入都需要刷新?這么些個問題,自己結合業(yè)務需求來做判斷,我這里為了簡單起見,直接放數(shù)據(jù)庫里了
- 這里的代碼是我簡化后的代碼,萬一刪代碼刪出了什么問題,歡迎評論區(qū)告訴我,我會第一時間修改
以上,希望能幫到大家。