第一期内容:
用户端登录
实现分析
登录功能
@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;
@Override
public 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=";
@Override
public String getPhone(String code) throws IOException {
//接口文档:https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-info/phone-number/getPhoneNumber.html
//1. 获取手机号,需要先获取微信access_token
String 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,不应该每次都获取token
return jsonObject.getStr("access_token");
}
String errMsg = StrUtil.format("调用获取接口调用凭据接口出错!");
throw new SLWebException(errMsg);
}
实现登录
/**
* 登录
*
* @param userLoginRequestVO 登录code
* @return 用户信息
*/
@Override
public 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_";
@Override
public 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,响应结果
@Override
public 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;
}
//删除原token
this.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));
}
@Override
public UserLoginVO refresh(String refreshToken) {
return this.tokenService.refreshToken(refreshToken);
}