第一期内容:
物流项目第一期(登录业务)-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);}