## 统一的账户管理体系



在商城的项目中，有一个统一认证的服务(mall4cloud-auth)，用于统一的认证登录。

![](./img/账户体系/账户体系.png)



我们抽取了一个统一的账户体系，如果查看`mall4cloud_auth` 这个数据库，里面有张表`auth_account` 这里面有几个值得注意的字段：

- `sys_type`：0.普通用户系统 1.商家端 2平台端，代表不同系统的用户体系
-  `user_id`：这里的用户id，是根据系统不用关联的内容也不同
  - 用户系统代表的是 `mall4cloud-user`数据库`user`表`user_id` 字段
  - 商家端代表的是 `mall4cloud_multishop`数据库`shop_user`表`shop_user_id` 字段
  - 平台端代表的是 `mall4cloud_platform`数据库`sys_user`表`sys_user_id` 字段

- `uid`：全平台用户唯一id

- `tenant_id`：所属租户，在商家端代表`shop_id`，其他地方暂时无意义
- 因为是统一的登录平台，所以登录使用的用户名、密码、手机号、邮箱等都是存储在这个服务里面



除了账号密码登录以外，还有第三方登录，比如微信小程序、微信公众号的`open_id`，这些就存在 `mall4cloud-auth`数据库`auth_social`表`biz_user_id`字段上



除了登录以外，系统还会有用户角色权限的模型，我们单独为这个模型抽取了个 `rbac`的服务。如果公司还有自己的统一登录服务，那么无需二次剥离。





## 校验token

有几点需要注意的是：

1. 在商城的项目中，并没有使用 `jwt` 进行登录授权的流程，使用的是传统的 `token` 形式，`token` 存储在redis中
2. 由于掌握`spring security`的人员较少，而我们只要做校验token，获取token的工作，所以是自己编写的token相关代码

对于一个有网关的微服务来说，将网关服务暴露到公网，其他服务都在内网，这是一个合理的做法，然而实际上很多公司的运维人员不懂。就导致了本应该在内网的东西（因为很多人都认为在内网的服务不需要通过校验），这就导致了`nacos` 著名的漏洞（百度一下`nacos漏洞`即可找到相关资料），有很多人将`nacos` 放到公网上，而实际上数据库的账号密码、配置文件的信息，都被暴露了，黑客用工具扫描`8848`端口，找到对应的服务就开始进行攻击了...当然这个问题在2021年得以修复...



所以商城的授权校验并不是在网关进行的授权校验，而是在每个服务之间都有校验的代码。

我们查看 `AuthFilter` 这个类，可以看到

```java
// 获取前端传入的token
String accessToken = req.getHeader("Authorization");

// 校验token
ServerResponseEntity<UserInfoInTokenBO> userInfoInTokenVoServerResponseEntity = tokenFeignClient
				.checkToken(accessToken);

UserInfoInTokenBO userInfoInToken = userInfoInTokenVoServerResponseEntity.getData();

try {
    // 保存token上下文
    AuthUserContext.set(userInfoInToken);

    chain.doFilter(req, resp);
}
finally {
    AuthUserContext.clean();
}
```

这样不仅保证了服务器的安全，还方便后面的方法获取用户id，店铺id等参数

```java
// 可以在全局获取登录的用户id
Long userId = AuthUserContext.get().getUserId();
```

在上面校验token的时候，使用的是`tokenFeignClient` 进行校验，从名字来看就是一个`feign` 请求，那这个请求连接的是哪个服务呢？实际上连接的是 `mall4cloud-auth` 这个服务。因为统一的登录认证服务已经被抽取出来了~



除了用户通过网关直接连接服务器以外，服务器与服务器之间也要连接，比如用户下单的时候，不仅要经过订单服务，还要经过优惠券服务查看有没有优惠券，`feign`请求默认是不会将http请求头信息转发到另外一个服务的，需要我们手动拦截转发。参考`FeignBasicAuthRequestInterceptor`

```java
	@Override
    public void apply(RequestTemplate template) {

        HttpServletRequest request = attributes.getRequest();
        String authorization = request.getHeader("Authorization");

        if (StrUtil.isNotBlank(authorization)) {
            template.header("Authorization", authorization);
        }
    }
```





## 登录生成token



> 当然既然需要验证，那么验证的`AccessToken`又来自哪里呢？
>
> 答：其实还是来自`mall4cloud-auth`服务，因为这里是做统一授权认证的。



我们看下`LoginController` 这个类，有个保存token的方法

```java
tokenStore.storeAndGetVo(data);
```

这里面一次登录，保存了3个数据到`redis`当中`token`，我们看下这三个值的作用

- 根据`AccessToken`获取用户信息的key，这个key对应的value存储了用户id、用户openid等信息，详情请看`UserInfoInTokenBO`

```java
public String getAccessKey(String accessToken) {
	return CacheNames.ACCESS + accessToken;
}
```

- 根据`RefreshToken`获取`AccessToken`的key，可以通过这个key可以获取token，然后删除旧的token，并创建一个新的token

```java
	public String getRefreshToAccessKey(String refreshToken) {
		return CacheNames.REFRESH_TO_ACCESS + refreshToken;
	}
```

- 根据`uid`获取存储过的token信息，可以通过这个key，获取已经创建过的`RefreshToken`和`AccessToken`，进行删除，将用户直接退出登录

```java
public String getUidToAccessKey(String approvalKey) {
   return CacheNames.UID_TO_ACCESS + approvalKey;
}
```

 



## 第三方登录

>  除了账号密码登录，我们还需要第三方登录，比如进入到微信小程序、公众号的时候就自动登录了。



我们来看那么一个流程图：

![](./img/账户体系/社交登录.png)

我们期望的是一进入系统就马上登录成功，比如通过微信打开的网页，一进去就直接登录了。这是该如何做到呢？如果了解`oauth2.0`的协议，应该清楚从`code`是可以拿到用户的信息，像`openid`。但code是有时间限制的，如果前端一开始就拿到了`code` 但是用户尚未进行注册或者绑定过账户信息，那么当用户填写完注册需要填写的资料，code已经过期了怎么办？

在商城系统中，当前端获取到code的时候，会直接传到后台服务器，后台回去微信服务器换取`openid`等信息并保存到数据库，返回一个变量（这个变量就没必要过期了），绑定账号的时候，直接查询数据库，就不会过期了，具体可以查看`SocialLoginController`



## 服务之间调用的安全问题

当有`AccessToken`进行操作的时候，会通过授权服务进行校验，可以确保安全。如果订单服务收到一条MQ消息，让订单变成确认收货，并且往商家的钱包进行增加余额的时候。MQ的消息并不会携带`token` 那该怎么办？如何确保商家服务收到的消息一定是来自我们商城的，而不是恶意消息。

一个最简单的办法就是除了网关服务，其他服务都在内网。而实际上难免运维人员会出错。

还有一个方法，就是我们参考用户的账号密码登录，每次获取到消息的时候，我们都校验一下服务器的“用户名密码”是否正确。

在`AuthFilter` 中有一个校验方法，校验`/feign`开头的请求：

```java
private boolean feignRequestCheck(HttpServletRequest req) {
   // 不是feign请求，不用校验
   if (!req.getRequestURI().startsWith(FeignInsideAuthConfig.FEIGN_INSIDE_URL_PREFIX)) {
      return true;
   }
   String feignInsideSecret = req.getHeader(feignInsideAuthConfig.getKey());

   // 校验feign 请求携带的key 和 value是否正确
   if (StrUtil.isBlank(feignInsideSecret) || !Objects.equals(feignInsideSecret,feignInsideAuthConfig.getSecret())) {
      return false;
   }
   // ip白名单
   List<String> ips = feignInsideAuthConfig.getIps();
   // 移除无用的空ip
   ips.removeIf(StrUtil::isBlank);
   // 有ip白名单，且ip不在白名单内，校验失败
   if (CollectionUtil.isNotEmpty(ips)
         && !ips.contains(IpHelper.getIpAddr())) {
      logger.error("ip not in ip White list: {}, ip, {}", ips, IpHelper.getIpAddr());
      return false;
   }
   return true;
}
```

有地方要校验，那么是什么时候传进去的呢：

我们回头看下`FeignBasicAuthRequestInterceptor`

```java
// feign的内部请求，往请求头放入key 和 secret进行校验
 template.header(feignInsideAuthConfig.getKey(), feignInsideAuthConfig.getSecret());
```

这里面的key和secret可以在`nacos`的`application-xxx.yml`进行配置

```yaml
feign:
  inside:
    key: mall4cloud-feign-inside-key
    secret: mall4cloud-feign-inside-secret
    # ip白名单，如果有需要的话，用小写逗号分割
    ips: 
```

