第一期內容:
物流項目第一期(登錄業務)-CSDN博客
用戶端登錄
實現分析
登錄功能?
@Data
public class UserLoginRequestVO {@ApiModelProperty("登錄臨時憑證")private String code;@ApiModelProperty("手機號臨時憑證")private String phoneCode;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserLoginVO {@ApiModelProperty("微信唯一標識符")private String openid;@ApiModelProperty("短令牌,有效期較短")private String accessToken;@ApiModelProperty("長令牌,有效期較長")private String refreshToken;@ApiModelProperty("是否綁定手機號 0否 1是")private Integer binding;}
小程序登錄
@Value("${sl.wechat.appid}")private String appid;@Value("${sl.wechat.secret}")private String secret;public static final String LOGIN_URL = "https://api.weixin.qq.com/sns/jscode2session";private static final int TIMEOUT = 20000;@Overridepublic JSONObject getOpenid(String code) throws IOException {//文檔:https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-login/code2Session.html//1. 封裝參數Map<String, Object> requestParam = MapUtil.<String, Object>builder().put("appid", this.appid) //小程序 appId.put("secret", this.secret) //小程序 appSecret.put("js_code", code) // 登錄時獲取的 code,可通過wx.login獲取.put("grant_type", "authorization_code") //授權類型.build();//2. 發送get請求HttpResponse response = HttpRequest.get(LOGIN_URL) //設置get請求url.form(requestParam) //設置表單參數.timeout(TIMEOUT) //設置超時時間,20s.execute();//執行請求if (response.isOk()) {// 3. 解析響應的結果,如果出現錯誤拋出異常JSONObject jsonObject = JSONUtil.parseObj(response.body());if (jsonObject.containsKey("errcode")) {throw new SLWebException(jsonObject.toString());}return jsonObject;}String errMsg = StrUtil.format("調用微信登錄接口出錯! code = {}", code);throw new SLWebException(errMsg);}
獲取手機號
public static final String TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token";public static final String PHONE_URL = "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=";@Overridepublic String getPhone(String code) throws IOException {//接口文檔:https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-info/phone-number/getPhoneNumber.html//1. 獲取手機號,需要先獲取微信access_tokenString accessToken = this.getToken();//2. 封裝參數Map<String, Object> requestParam = MapUtil.<String, Object>builder().put("code", code) //手機號獲取憑證.build();//3. 發送post請求HttpResponse response = HttpRequest.post(PHONE_URL + accessToken) //設置post請求url.body(JSONUtil.toJsonStr(requestParam)) //設置請求體參數.timeout(TIMEOUT) //設置超時時間,20s.execute();//執行請求if (response.isOk()) {// 4. 解析響應的結果,如果errcode不等于0拋出異常JSONObject jsonObject = JSONUtil.parseObj(response.body());if (ObjectUtil.notEqual(jsonObject.getInt("errcode"), 0)) {throw new SLWebException(jsonObject.toString());}return jsonObject.getByPath("phone_info.purePhoneNumber", String.class);}String errMsg = StrUtil.format("調用獲取手機號接口出錯!");throw new SLWebException(errMsg);}private String getToken() {//接口文檔:https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/mp-access-token/getAccessToken.html//1. 封裝參數Map<String, Object> requestParam = MapUtil.<String, Object>builder().put("appid", this.appid) //小程序 appId.put("secret", this.secret) //小程序 appSecret.put("grant_type", "client_credential") //授權類型.build();//2. 發送get請求HttpResponse response = HttpRequest.get(TOKEN_URL) //設置get請求url.form(requestParam) //設置表單參數.timeout(TIMEOUT) //設置超時時間,20s.execute();//執行請求if (response.isOk()) {// 3. 解析響應的結果,如果出現錯誤拋出異常JSONObject jsonObject = JSONUtil.parseObj(response.body());if (jsonObject.containsKey("errcode")) {throw new SLWebException(jsonObject.toString());}//TODO 緩存token到redis,不應該每次都獲取tokenreturn jsonObject.getStr("access_token");}String errMsg = StrUtil.format("調用獲取接口調用憑據接口出錯!");throw new SLWebException(errMsg);}
實現登錄
/*** 登錄** @param userLoginRequestVO 登錄code* @return 用戶信息*/@Overridepublic UserLoginVO login(UserLoginRequestVO userLoginRequestVO) throws IOException {//1. 調用微信開發平臺的接口,根據臨時登錄code獲取openid等信息JSONObject jsonObject = this.wechatService.getOpenid(userLoginRequestVO.getCode());String openid = jsonObject.getStr("openid");//2. 根據openid來確認是否為新用戶,新用戶進行注冊,老用戶無需直接注冊MemberDTO memberDTO = this.getByOpenid(openid);if (ObjectUtil.isEmpty(memberDTO)) {//新用戶MemberDTO newMember = MemberDTO.builder().openId(openid) //設置openid.authId(jsonObject.getStr("unionid")) //設置平臺唯一id,若當前小程序已綁定到微信開放平臺帳號下會返回.build();//注冊用戶this.save(newMember);//再次查詢用戶信息memberDTO = this.getByOpenid(openid);}//3. 調用微信開發平臺的接口,獲取用戶手機號,如果用戶手機號有更新,需要進行更新操作String phone = this.wechatService.getPhone(userLoginRequestVO.getPhoneCode());if (ObjectUtil.notEqual(phone, memberDTO.getPhone())) {//更新手機號memberDTO.setPhone(phone);this.memberFeign.update(memberDTO.getId(), memberDTO);}//4. 生成token,將用戶id存儲到token中Map<String, Object> claims = MapUtil.<String, Object>builder().put(Constants.GATEWAY.USER_ID, memberDTO.getId()) //將id存入token.build();String accessToken = this.tokenService.createAccessToken(claims);//5. 返回封裝響應數據return UserLoginVO.builder().openid(openid).accessToken(accessToken).binding(StatusEnum.NORMAL.getCode()).build();}
public String createAccessToken(Map<String, Object> claims) {//生成短令牌的有效期時間單位為:分鐘return JwtUtils.createToken(claims, jwtProperties.getPrivateKey(), jwtProperties.getAccessTtl(),DateField.MINUTE);}
登錄流程總結
+-----------------------+
| 小程序端 |
+-----------------------+|v
+-----------------------+
| 調用微信登錄接口 |
| 獲取 openid |
+-----------------------+|v
+-----------------------+
| 根據 openid 注冊/查詢用戶 |
+-----------------------+|v
+-----------------------+
| 調用微信獲取手機號接口 |
| 需要先獲取 access_token |
+-----------------------+|v
+-----------------------+
| 更新用戶手機號(如有變化)|
+-----------------------+|v
+-----------------------+
| 生成你自己的 JWT Token |
| 返回給前端 |
+-----------------------+
雙token三驗證
單token存在的問題
在司機端、快遞員端和管理管,登錄成功后會生成jwt的token,前端將此token保存起來,當請求后端服務時,在請求頭中攜帶此token,服務端需要對token進行校驗以及鑒權操作,這種模式就是【單token模式】。
該模式存在什么問題嗎?
其實是有問題的,主要是token有效期設置長短的問題,如果設置的比較短,用戶會頻繁的登錄,如果設置的比較長,會不太安全,因為token一旦被黑客截取的話,就可以通過此token與服務端進行交互了。
另外一方面,token是無狀態的,也就是說,服務端一旦頒發了token就無法讓其失效(除非過了有效期),這樣的話,如果我們檢測到token異常也無法使其失效,所以這也是無狀態token存在的問題。
為了解決此問題,我們將采用【雙token三驗證】的解決方案來解決此問題。
方案原理?
代碼實現?
生成刷新token
public static final String REDIS_REFRESH_TOKEN_PREFIX = "SL_CUSTOMER_REFRESH_TOKEN_";@Overridepublic String createRefreshToken(Map<String, Object> claims) {//生成長令牌的有效期時間單位為:小時Integer ttl = jwtProperties.getRefreshTtl();String refreshToken = JwtUtils.createToken(claims, jwtProperties.getPrivateKey(), ttl);//長令牌只能使用一次,需要將其存儲到redis中,變成有狀態的String redisKey = this.getRedisRefreshToken(refreshToken);this.stringRedisTemplate.opsForValue().set(redisKey, refreshToken, Duration.ofHours(ttl));return refreshToken;}private String getRedisRefreshToken(String refreshToken) {//md5是為了縮短key的長度return REDIS_REFRESH_TOKEN_PREFIX + SecureUtil.md5(refreshToken);}
刷新token
刷新token的動作是在refresh_token過期之后進行的,主要實現關鍵點有:
- 校驗refresh_token是否被偽造以及是否在有效期內
- 從redis中查詢,是否不存在,如果不存在說明已經失效或已經使用過,如果存在,就需要將其刪除
- 重新生成一對token,響應結果
@Overridepublic UserLoginVO refreshToken(String refreshToken) {if (StrUtil.isEmpty(refreshToken)) {return null;}Map<String, Object> originClaims = JwtUtils.checkToken(refreshToken, this.jwtProperties.getPublicKey());if (ObjectUtil.isEmpty(originClaims)) {//token無效return null;}//通過redis校驗,原token是否使用過,來確保token只能使用一次String redisKey = this.getRedisRefreshToken(refreshToken);Boolean bool = this.stringRedisTemplate.hasKey(redisKey);if (ObjectUtil.notEqual(bool, Boolean.TRUE)) {//原token過期或已經使用過return null;}//刪除原tokenthis.stringRedisTemplate.delete(redisKey);//重新生成長短令牌String newRefreshToken = this.createRefreshToken(originClaims);String accessToken = this.createAccessToken(originClaims);return UserLoginVO.builder().accessToken(accessToken).refreshToken(newRefreshToken).build();}
/*** 刷新token,校驗請求頭中的長令牌,生成新的長短令牌** @param refreshToken 原令牌* @return 登錄結果*/@PostMapping("/refresh")@ApiOperation("刷新token")public R<UserLoginVO> refresh(@RequestHeader(Constants.GATEWAY.REFRESH_TOKEN) String refreshToken) {return R.success(this.memberService.refresh(refreshToken));}
@Overridepublic UserLoginVO refresh(String refreshToken) {return this.tokenService.refreshToken(refreshToken);}