用户端登录#
用户端的登录是基于微信小程序登录
wx小程序搭建:前端部署文档 ↗
env.js中修改本地接口

可以看到本地请求为502
这是由于本地网关并没有配置导致的后面会进行配置

请求参数(2个参数,code:登录临时凭证,phoneCode:获取手机号的凭证):

拉取后端代码 sl-express-ms-web-customer ↗
登录接口#
登录接口的实现是在com.sl.ms.web.customer.controller.UserController#login中

可以看到代码是由UserLoginVO响应数据对象和UserLoginRequestVO请求参数对象组成
我们现看看UserLoginVO响应数据对象
package com.sl.ms.web.customer.vo.user;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@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;
}javaUserLoginRequestVO请求参数对象组成
package com.sl.ms.web.customer.vo.user;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
/**
* C端用户登录
*/
@Data
public class UserLoginRequestVO {
@ApiModelProperty("登录临时凭证")
private String code;
@ApiModelProperty("手机号临时凭证")
private String phoneCode;
}java用户微服务#
用户端通过微信登陆流程图

在Jenkins部署sl-express-ms-user-service ↗


数据库已在服务器部署
小程序登录#
@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);
}java
通过小程序登录测试

获取手机号#
想要获取到手机号的要先获取到微信access_token,在获取到手机号,否则获取不到
com.sl.ms.web.customer.service.impl


@Test
void getPhone() throws IOException {
//在测试用例中输入自己获取到的 phonecode
String phone = wechatService.getPhone("1aaaaa1111");
System.out.println(phone);
}java实现登录#
com.sl.ms.web.customer.service.impl.MemberServiceImpl#login

生成token实现,这里采用的是RSA加密方式(密钥信息在nacos中的sl-express-ms-web-customer.properties配置)
com.sl.ms.web.customer.service.impl.TokenServiceImp

启动服务,在小程序进行测试

可以看到登录成功
双token三验证#
单token存在的问题#
在日常使用中,我们会频繁的进行登录,而在登录的过程中,会先生成jwt的token,前端接收到token将其保存下来,在进行后端服务请求的时候,在请求中携带token,服务端对token进行校验和鉴权,这种模式叫做 单token模式
在单token模式中,会出现token有效时间的问题,有效时间太短容易过期,太长容易被攻击截取。
在另一方面,token是无状态的,就是说我们不能自如的控制token的状态,无法让他主动失效。
方案设计#
解决问题
-
token有效期长不安全
-
登录成功后,生成2个token,分别是:access_token、refresh_token,前者有效期短(如:5分钟),后者的有效期长(如:24小时)
-
正常请求后端服务时,携带access_token,如果发现access_token失效,就通过refresh_token到后台服务中换取新的access_token和refresh_token,这个可以理解为token的续签
-
以此往复,直至refresh_token过期,需要用户重新登录
-
-
token的无状态性
-
为了使token有状态,也就是后端可以控制其提前失效,需要将refresh_token设计成只能使用一次
-
需要将refresh_token存储到redis中,并且要设置过期时间
-
这样的话,服务端如果检测到用户token有安全隐患(如:异地登录),只需要将refresh_token失效即可
-
代码实现#
生成刷新token#
生成刷新refresh_token的主要逻辑有两点:
- 生成jwt格式的token,有效期时间一般小时为单位
- 将token存入到redis,使token有状态,并且确保只能使用一次
com.sl.ms.web.customer.service.impl.TokenServiceImpl
public static final String REDIS_REFRESH_TOKEN_PREFIX = "SL_CUSTOMER_REFRESH_TOKEN_";java
刷新token#
刷新token的动作是在refresh_token过期之后进行的,主要实现关键点有:
- 校验refresh_token是否被伪造以及是否在有效期内
- 从redis中查询,是否不存在,如果不存在说明已经失效或已经使用过,如果存在,就需要将其删除
- 重新生成一对token,响应结果
com.sl.ms.web.customer.service.impl.TokenServiceImpl

在Controller中调用上述方法
com.sl.ms.web.customer.controller.UserController

Service中调用
com.sl.ms.web.customer.service.impl.MemberServiceImpl

完善用户端登录#
用户端登录成功后,需要在补一个token,之前已经写过一个了

测试

网关#
网关作用#
网关能够更好的管理项目分配,每一个客户端都将通过网关传递到服务端
网关就是每个客户端的中间商,他还能对token、以及用户的权限进行有效的校验
比如说:司机不能登录到快递员端,快递员不能登录到司机端

拉取代码#
从sl-express-gateway ↗中拉取网关代码:
拉取代码后,创建develop分支进行开发
bootstrap-local.yml
server:
port: 9527
tomcat:
uri-encoding: UTF-8
threads:
max: 1000
min-spare: 30
spring:
cloud:
nacos:
username: nacos
password: nacos
server-addr: 192.168.150.101:8848
discovery:
namespace: ecae68ba-7b43-4473-a980-4ddeb6157bdc
ip: 192.168.150.1
config:
namespace: ecae68ba-7b43-4473-a980-4ddeb6157bdc
gateway:
routes:
- id: sl-express-ms-web-manager #路由标识,需要唯一
uri: lb://sl-express-ms-web-manager #最终请求转发的微服务,指定的是微服务名,并开启负载均衡
predicates: #断言
- Path=/manager/**
filters: #配置过滤器
- ManagerToken #自定义局部过滤器
- StripPrefix=1 #去掉第一个路径,例如:/manager/user/login -转发到下游微服务-> /user/login
- AddRequestHeader=X-Request-From, sl-express-gateway #转发到下游微服务携带的请求头,增强安全性
globalcors:
cors-configurations:
'[/**]':
allowed-origin-patterns: '*'
allowed-headers: '*'
allow-credentials: true
allowed-methods:
- GET
- POST
- DELETE
- PUT
- OPTION
discovery:
locator:
enabled: true #表明gateway开启服务注册和发现的功能,并且spring cloud gateway自动根据服务发现为每一个服务创建了一个router,这个router将以服务名开头的请求路径转发到对应的服务
itcast:
authority:
host: ${authority.host} #authority服务地址,根据实际情况更改
port: ${authority.port} #authority服务端口
timeout: ${authority.timeout} #http请求的超时时间
public-key-file: auth/pub.key
applicationId: ${authority.applicationId}
#角色id
role:
manager: ${role.manager}
courier: ${role.courier}
driver: ${role.driver}
sl:
noAuthPaths:
- /courier/login/account
- /courier/swagger-ui.html
- /courier/webjars/
- /courier/swagger-resources
- /courier/v2/api-docs
- /courier/doc.html
- /customer/user/login
- /customer/user/refresh
- /customer/swagger-ui.html
- /customer/webjars/
- /customer/swagger-resources
- /customer/v2/api-docs
- /customer/doc.html
- /driver/login/account
- /driver/swagger-ui.html
- /driver/webjars/
- /driver/swagger-resources
- /driver/v2/api-docs
- /driver/doc.html
- /manager/login
- /manager/webjars/
- /manager/swagger-resources
- /manager/v2/api-docs
- /manager/doc.html
- /manager/captcha
jwt:
public-key: ${sl.jwt.user-secret-key}yaml自定义过滤器配置#

过滤器实现#
在过滤器中主要实现以下几个功能:
- 白名单放行
- 请求头中的token是否有效
- 权限是否匹配(校验角色)
- 向下游微服务传递解析token的数据以及token值
代码实现:
/**
* 管理端token校验的过滤器
*/
@Component
public class ManagerTokenGatewayFilterFactory extends AbstractGatewayFilterFactory<Object> {
@Resource
private MyConfig myConfig;
@Resource
private TokenCheckService tokenCheckService;
@Value("${role.manager}")
private List<Long> managerRoleIds; //获取配置文件中的管理员角色id
@Override
public GatewayFilter apply(Object config) {
return (exchange, chain) -> {
//1. 校验请求路径,如果是白名单,直接放行
String path = exchange.getRequest().getPath().toString();
if (StrUtil.startWithAny(path, this.myConfig.getNoAuthPaths())) {
//直接放行
return chain.filter(exchange);
}
//2. 获取请求头中的token,进行校验,如果为空或校验失效,响应401
String token = exchange.getRequest().getHeaders().getFirst(Constants.GATEWAY.AUTHORIZATION);
if (StrUtil.isEmpty(token)) {
//设置响应状态为401
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
//拦截请求
return exchange.getResponse().setComplete();
}
//校验token
AuthUserInfoDTO authUserInfoDTO = null;
try {
authUserInfoDTO = this.tokenCheckService.parserToken(token);
} catch (Exception e) {
//token不可用,不做处理
}
if (ObjectUtil.isEmpty(authUserInfoDTO)) {
//token不可用,设置响应状态为401
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
//拦截请求
return exchange.getResponse().setComplete();
}
//3. 校验权限,如果是非管理员不能登录
AuthTemplate authTemplate = AuthTemplateFactory.get(token);
//3.1 获取用户拥有的角色id列表
List<Long> roleIds = authTemplate.opsForRole().findRoleByUserId(authUserInfoDTO.getUserId()).getData();
//3.2 取交集,判断用户拥有的角色是否与预定的角色列表是否有交集
Collection<Long> intersection = CollUtil.intersection(roleIds, this.managerRoleIds);
if (CollUtil.isEmpty(intersection)) {
//无交集,说明没有权限,设置响应状态码为400
exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST);
return exchange.getResponse().setComplete();
}
//4. 校验通过,向下游传递用户信息和token
exchange.getRequest().mutate().header(Constants.GATEWAY.USERINFO, JSONUtil.toJsonStr(authUserInfoDTO));
exchange.getRequest().mutate().header(Constants.GATEWAY.TOKEN, token);
//4.1 校验通过放行
return chain.filter(exchange);
};
}
}java测试#
将网关启动,测试http://127.0.0.1:9527/manager/doc.html#/home看能不能正常看到页面 ↗

配置前端
将测试URL改为VUE_APP_BASE_URL = 'http://192.168.150.1:9527/manager'

将代码上传git
在Jenkins构造项目
访问http://admin.sl-express.com/ ↗

验证码端口可以看到是9527,部署成功

代码优化#
优化一#
将校验逻辑抽取到独立的类中,这样在每个端的GatewayFilterFactory中就可以共用了。
com.sl.gateway.filter.TokenGatewayFilter
package com.sl.gateway.filter;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.itheima.auth.factory.AuthTemplateFactory;
import com.itheima.auth.sdk.AuthTemplate;
import com.itheima.auth.sdk.dto.AuthUserInfoDTO;
import com.itheima.auth.sdk.service.TokenCheckService;
import com.sl.gateway.config.MyConfig;
import com.sl.transport.common.constant.Constants;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import javax.annotation.Resource;
import java.util.Collection;
import java.util.List;
@Component
public class TokenGatewayFilter implements GatewayFilter {
@Resource
private MyConfig myConfig;
@Resource
private TokenCheckService tokenCheckService;
@Value("${role.manager}")
private List<Long> managerRoleIds; //获取配置文件中的管理员角色id
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//1. 校验请求路径,如果是白名单,直接放行
String path = exchange.getRequest().getPath().toString();
if (StrUtil.startWithAny(path, this.myConfig.getNoAuthPaths())) {
//直接放行
return chain.filter(exchange);
}
//2. 获取请求头中的token,进行校验,如果为空或校验失效,响应401
String token = exchange.getRequest().getHeaders().getFirst(Constants.GATEWAY.AUTHORIZATION);
if (StrUtil.isEmpty(token)) {
//设置响应状态为401
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
//拦截请求
return exchange.getResponse().setComplete();
}
//校验token
AuthUserInfoDTO authUserInfoDTO = null;
try {
authUserInfoDTO = this.tokenCheckService.parserToken(token);
} catch (Exception e) {
//token不可用,不做处理
}
if (ObjectUtil.isEmpty(authUserInfoDTO)) {
//token不可用,设置响应状态为401
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
//拦截请求
return exchange.getResponse().setComplete();
}
//3. 校验权限,如果是非管理员不能登录
AuthTemplate authTemplate = AuthTemplateFactory.get(token);
//3.1 获取用户拥有的角色id列表
List<Long> roleIds = authTemplate.opsForRole().findRoleByUserId(authUserInfoDTO.getUserId()).getData();
//3.2 取交集,判断用户拥有的角色是否与预定的角色列表是否有交集
Collection<Long> intersection = CollUtil.intersection(roleIds, this.managerRoleIds);
if (CollUtil.isEmpty(intersection)) {
//无交集,说明没有权限,设置响应状态码为400
exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST);
return exchange.getResponse().setComplete();
}
//4. 校验通过,向下游传递用户信息和token
exchange.getRequest().mutate().header(Constants.GATEWAY.USERINFO, JSONUtil.toJsonStr(authUserInfoDTO));
exchange.getRequest().mutate().header(Constants.GATEWAY.TOKEN, token);
//4.1 校验通过放行
return chain.filter(exchange);
}
}
javacom.sl.gateway.filter.ManagerTokenGatewayFilterFactory
package com.sl.gateway.filter;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
/**
* 管理端token校验的过滤器
*/
@Component
public class ManagerTokenGatewayFilterFactory extends AbstractGatewayFilterFactory<Object> {
@Resource
private TokenGatewayFilter tokenGatewayFilter;
@Override
public GatewayFilter apply(Object config) {
return this.tokenGatewayFilter;
}
}
java优化2#
通过前面的优化一已经将过滤器抽取到一个独立的类中,这样就可以在多个过滤器工厂中通用了,但也是存在一些问题的,例如:
- 不同的终端校验的角色id是不同的
- 用户端是不需要校验角色的
- 用户端校验token的逻辑与其他三端是不一样的
- 用户端请求头中的token参数名与其他三端也不一样
基于这些问题,就能够意识到,单纯的抽取代码是不够的,需要进一步的优化。想一想,该怎么优化呢?
优化的思路就是,在TokenGatewayFilter中保留四端通用的逻辑,不同的逻辑抽取到接口中,由四端具体的实现。
自定义AuthFilter接口:
com.sl.gateway.filter.AuthFilter
package com.sl.gateway.filter;
import com.itheima.auth.sdk.dto.AuthUserInfoDTO;
import com.sl.transport.common.constant.Constants;
/**
* 鉴权业务的回调,具体逻辑由 GatewayFilterFactory 具体完成
*/
public interface AuthFilter {
/**
* 校验token
*
* @param token 请求中的token
* @return token中携带的数据
*/
AuthUserInfoDTO check(String token);
/**
* 鉴权
*
* @param token 请求中的token
* @param authUserInfo token中携带的数据
* @param path 当前请求的路径
* @return 是否通过
*/
Boolean auth(String token, AuthUserInfoDTO authUserInfo, String path);
/**
* 请求中携带token的名称
*
* @return 头名称
*/
default String tokenHeaderName() {
return Constants.GATEWAY.AUTHORIZATION;
}
}
java