## 第10章-单点登录
**学习目标:**
- 能够说出主流的认证方案-JWT/Token
- 搭建用户微服务模块
- 完成基于Token+Redis实现认证
- 完成网关统一鉴权
# 1、单点登录业务介绍
![img](assets/day10/wps1.png)
早期单一服务器,用户认证。
![img](assets/day10/wps2.jpg)
缺点:
- 单点性能压力,无法扩展
分布式,SSO(single sign on)模式
![img](assets/day10/wps3.jpg)
解决 :
- 用户身份信息独立管理,更好的分布式管理。
- 可以自己扩展安全策略
- 跨域不是问题
缺点:
- 认证服务器访问压力较大。
业务流程图 {用户访问业务时,必须登录的流程}{单点登录的过程}
![img](assets/day10/wps4.jpg)
# 2、用户模块
## 2.1 实现思路
1、 用接收的用户名密码核对后台数据库
2、 核对通过,用uuid生成token
3、 将用户id加载到写入redis,redis的key为token,value为用户id。
4、 登录成功返回token与用户信息,将token与用户信息记录到cookie里面
5、 重定向用户到之前的来源地址。
数据库表:user_info,并添加一条数据!**密码应该是加密的!**
## 2.2 搭建认证中心模块service-user
### 2.2.1 搭建service-user服务
在`gmall-service`模块下新增子模块:service-user。搭建方式如service-item
![image-20221206174842858](assets/day10/image-20221206174842858.png)
### 2.2.2 启动类
```java
package com.atguigu.gmall;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
@SpringBootApplication
@EnableDiscoveryClient
public class UserApp {
public static void main(String[] args) {
SpringApplication.run(UserApp.class, args);
}
}
```
### 2.2.3 配置pom.xml
```xml
gmall-service
com.atguigu.gmall
1.0
4.0.0
service-user
service-user
org.springframework.boot
spring-boot-maven-plugin
```
### 2.2.4 添加配置文件
在resources目录下新增 bootstrap.properties 文件
```properties
spring.application.name=service-user
spring.profiles.active=dev
spring.cloud.nacos.discovery.server-addr=192.168.200.128:8848
spring.cloud.nacos.config.server-addr=192.168.200.128:8848
spring.cloud.nacos.config.prefix=${spring.application.name}
spring.cloud.nacos.config.file-extension=yaml
spring.cloud.nacos.config.shared-configs[0].data-id=common.yaml
```
### 2.2.5 生成基础代码
在`mybatis-plus-code`中执行代码生成器代码,将`gmall_user`数据库中的 user_info,user_address 生成基础代码。
```java
package com.atguigu.mybatispluscode;
import com.atguigu.gmall.base.model.BaseEntity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.core.exceptions.MybatisPlusException;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.generator.AutoGenerator;
import com.baomidou.mybatisplus.generator.InjectionConfig;
import com.baomidou.mybatisplus.generator.config.*;
import com.baomidou.mybatisplus.generator.config.rules.DateType;
import com.baomidou.mybatisplus.generator.config.rules.NamingStrategy;
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;
import java.util.*;
/**
* 可参考:https://blog.csdn.net/weixin_44541136/article/details/121202871
*/
public class CodeGenerator {
public static void main(String[] args) {
// 设置代码生成的位置
// String projectPath = System.getProperty("user.dir");
String projectPath = "D:\\code\\workspace2023\\sph\\gmall-parent\\gmall-service";
// 设置父模块名称
String parentModuleName = "com.atguigu.gmall";
// 设置子模块名称
String moduleName = "user";
String subPath = "/service-" + moduleName;
// 设置数据库连接
String databaseUrl = "jdbc:mysql://127.0.0.1:3306/gmall_" + moduleName + "?useUnicode=true&useSSL=false&characterEncoding=utf8";
// 数据库用户名
String username = "root";
// 数据库密码
String password = "root";
// 设置表名前缀,例如表为tb_UserInfo,这里设置表前缀为"tb_",生成实体类的时候会自动去除前缀,最终生成UserInfo
String tablePrefix = "";
// 代码生成器
AutoGenerator mpg = new AutoGenerator();
// 全局配置
GlobalConfig gc = new GlobalConfig();
// 最终输出目录
gc.setOutputDir(projectPath + subPath + "/src/main/java");
gc.setAuthor("atguigu"); //作者
gc.setOpen(false); //是否打开输出目录
gc.setSwagger2(true); //实体属性 Swagger2 注解
gc.setDateType(DateType.ONLY_DATE); //时间类型为 Date LocalDateTime
// 设置主键类型 ASSIGN_ID为分布式全局唯一ID AUTO:数据库自增
gc.setIdType(IdType.AUTO);
//gc.setIdType(IdType.ASSIGN_ID);
// 是否覆盖已有文件
gc.setFileOverride(false);
//去掉Service接口的首字母I
gc.setServiceName("%sService");
mpg.setGlobalConfig(gc);
// 数据源配置
DataSourceConfig dsc = new DataSourceConfig();
dsc.setUrl(databaseUrl);
// dsc.setSchemaName("public");
dsc.setDriverName("com.mysql.jdbc.Driver");
dsc.setUsername(username);
dsc.setPassword(password);
mpg.setDataSource(dsc);
// 包配置
PackageConfig pc = new PackageConfig();
// pc.setModuleName(scanner("模块名"));
//设置实体类包名
pc.setEntity("model");
pc.setParent(parentModuleName);
pc.setModuleName(moduleName);
mpg.setPackageInfo(pc);
// 自定义配置
InjectionConfig cfg = new InjectionConfig() {
//自定义属性注入
//在.ftl(或者是.vm)模板中,通过${cfg.abc}获取属性
@Override
public void initMap() {
Map map = new HashMap<>();
map.put("parentName", parentModuleName);
map.put("moduleName", moduleName);
this.setMap(map);
}
};
mpg.setCfg(cfg);
// 配置模板
TemplateConfig templateConfig = new TemplateConfig();
templateConfig.setEntity("templates/entity2.java");
//指定自定义模板路径, 位置:/resources/templates/entity2.java.ftl(或者是.vm)
templateConfig.setMapper("templates/mapper2.java");
templateConfig.setService("templates/service2.java");
templateConfig.setServiceImpl("templates/serviceImpl2.java");
templateConfig.setController("templates/controller2.java");
mpg.setTemplate(templateConfig);
//TODO 根据需要来设置!!!例如:禁用模版的方式禁止生成实体类
templateConfig.disable(TemplateType.ENTITY);
// 策略配置
StrategyConfig strategy = new StrategyConfig();
strategy.setSuperEntityClass(BaseEntity.class);
//写于父类中的公共字段
strategy.setSuperEntityColumns("id", "create_time", "update_time", "is_deleted");
strategy.setNaming(NamingStrategy.underline_to_camel); //数据库表映射到实体的命名策略
strategy.setColumnNaming(NamingStrategy.underline_to_camel); //数据库表字段映射到实体的命名策略
strategy.setEntityLombokModel(true); //【实体】是否为lombok模型(默认 false)
strategy.setRestControllerStyle(true);
strategy.setEntityTableFieldAnnotationEnable(true); //是否生成实体时,生成字段注解
strategy.setInclude(scanner("表名,多个英文逗号分割").split(","));
strategy.setControllerMappingHyphenStyle(true);
strategy.setTablePrefix(tablePrefix);
mpg.setStrategy(strategy);
mpg.setTemplateEngine(new FreemarkerTemplateEngine());
mpg.execute();
}
/**
*
* 读取控制台内容
*
*/
public static String scanner(String tip) {
Scanner scanner = new Scanner(System.in);
StringBuilder help = new StringBuilder();
help.append("请输入" + tip + ":");
System.out.println(help.toString());
if (scanner.hasNext()) {
String ipt = scanner.next();
if (StringUtils.isNotBlank(ipt)) {
return ipt;
}
}
throw new MybatisPlusException("请输入正确的" + tip + "!");
}
}
```
### 2.2.4 封装登录接口
> YAPI接口地址:
>
> - 登录 http://192.168.200.128:3000/project/11/interface/api/803
> - 退出 http://192.168.200.128:3000/project/11/interface/api/811
#### 2.2.4.1 控制器
```java
package com.atguigu.gmall.user.controller;
import com.atguigu.gmall.common.result.Result;
import com.atguigu.gmall.user.model.UserInfo;
import io.swagger.annotations.Api;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.atguigu.gmall.user.service.UserInfoService;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
/**
* 用户表 前端控制器
*
* @author atguigu
* @since 2022-12-15
*/
@Api(tags = "用户表控制器")
@RestController
@RequestMapping("/api/user/passport")
public class PassportController {
@Autowired
private UserInfoService userInfoService;
/**
* 用户登录
* @param loginInfo
* @return
*/
@PostMapping("/login")
public Result login(@RequestBody UserInfo loginInfo, HttpServletRequest request){
return userInfoService.login(loginInfo, request);
}
/**
* 用户退出
* @param token 请求头中token令牌
* @return
*/
@GetMapping("/logout")
public Result logout(@RequestHeader("token") String token){
userInfoService.logout(token);
return Result.ok();
}
}
```
#### 2.2.4.2 业务层
业务接口:UserInfoService
```java
package com.atguigu.gmall.user.service;
import com.atguigu.gmall.common.result.Result;
import com.atguigu.gmall.user.model.UserInfo;
import com.baomidou.mybatisplus.extension.service.IService;
import javax.servlet.http.HttpServletRequest;
/**
* 用户表 业务接口类
* @author atguigu
* @since 2023-01-10
*/
public interface UserInfoService extends IService {
/**
* 用户登录
* @param loginInfo
* @param request
* @return
*/
Result login(UserInfo loginInfo, HttpServletRequest request);
/**
* 用户退出系统
* @param token
*/
void logout(String token);
}
```
业务实现类:
```java
package com.atguigu.gmall.user.service.impl;
import com.alibaba.fastjson.JSONObject;
import com.atguigu.gmall.common.constant.RedisConst;
import com.atguigu.gmall.common.result.Result;
import com.atguigu.gmall.common.util.IpUtil;
import com.atguigu.gmall.user.model.UserInfo;
import com.atguigu.gmall.user.mapper.UserInfoMapper;
import com.atguigu.gmall.user.service.UserInfoService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.DigestUtils;
import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* 用户表 业务实现类
*
* @author atguigu
* @since 2023-01-10
*/
@Service
public class UserInfoServiceImpl extends ServiceImpl implements UserInfoService {
//@Autowired
//private UserInfoMapper userInfoMapper;
@Autowired
private RedisTemplate redisTemplate;
/**
* 用户登录
*
* @param loginInfo
* @param request
* @return
*/
@Override
public Result login(UserInfo loginInfo, HttpServletRequest request) {
//1.验证用户信息是否合法-根据根据用户名密码查询用户记录--注意:数据库中密码为密文
//1.1 对用户填写密码进行md5加密
String userPwd = DigestUtils.md5DigestAsHex(loginInfo.getPasswd().getBytes());
//1.2 根据用户账户+用户填写加密后密码 进行查询
LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(UserInfo::getLoginName, loginInfo.getLoginName());
queryWrapper.eq(UserInfo::getPasswd, userPwd);
UserInfo userInfo = getOne(queryWrapper);
if (userInfo == null) {
return Result.fail().message("用户名或者密码错误!");
}
//2.如果用户认证通过,将用户信息以及Token存入Redis 设置有限期7天
//2.1 生成存入redis 令牌UUID
String uuid = UUID.randomUUID().toString().replaceAll("-", "");
String redisKey = RedisConst.USER_LOGIN_KEY_PREFIX + uuid;
//2.2 生成存入redis中用户信息
JSONObject jsonObject = new JSONObject();
jsonObject.put("userId", userInfo.getId());
jsonObject.put("ip", IpUtil.getIpAddress(request));
redisTemplate.opsForValue().set(redisKey, jsonObject, RedisConst.USERKEY_TIMEOUT, TimeUnit.SECONDS);
//3.按照接口文档要求封装响应业务数据{token:"UUID令牌", data:用户信息}
HashMap data = new HashMap<>();
data.put("token", uuid);
data.put("nickName", userInfo.getNickName());
return Result.ok(data);
}
/**
* 用户退出,将登录后存储在Redis中token删除即可
*
* @param token
*/
@Override
public void logout(String token) {
String redisKey = RedisConst.USER_LOGIN_KEY_PREFIX + token;
redisTemplate.delete(redisKey);
}
}
```
#### 2.2.4.3 持久层
```java
package com.atguigu.gmall.user.mapper;
import com.atguigu.gmall.model.user.UserInfo;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
public interface UserInfoMapper extends BaseMapper {
}
```
## 2.3 配置网关路由
在Nacos配置列表中,`server-gateway-dev.yaml`进行编辑增加动态路由
```yaml
- id: service-user
uri: lb://service-user
predicates:
- Path=/*/user/**
- id: web-passport
uri: lb://web-all
predicates:
- Host=passport.gmall.com
```
## 2.4 在web-all模块添加实现
### 2.4.1 在web-all 项目中跳转页面
```java
package com.atguigu.gmall.web.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpServletRequest;
/**
*
* 用户认证接口
*
*
*/
@Controller
public class PassportController {
/**
* 渲染登录页面
* @param originUrl 登录前用户访问地址,登录成功后重定向到该地址
* @return
*/
@GetMapping("/login.html")
public String login(Model model, @RequestParam("originUrl") String originUrl){
model.addAttribute("originUrl", originUrl);
return "login.html";
}
}
```
### 2.4.2 登录页面
页面资源: \templates\login1.html
login1.html 没有公共头部信息
login.html 有公共头部信息
![img](assets/day10/wps5.jpg)
Html关键代码
```html
```
## 2.5 头部信息处理
web-all项目:common/header.html,common/head.html
功能:头部信息为公共信息,所有页面都具有相关的头部,所以我们可以单独提取出来,头部页面显示登录状态与关键字搜索等信息
### 2.5.1 提取头部信息
提取头部信息我们会用到thymeleaf 两个标签:
> **th****:fragment****:定义代码块**
>
> **th****:include****:将代码块片段包含的内容插入到使用了th:include的HTML标签中**
1,定义头部代码块(/common/header.html),关键代码
```html
```
2,在其他页面引用头部代码块
```html
```
### 2.5.2 头部登录状态处理
思路:登录成功后我们将用户信息写入了cookie,所以我们判断cookie中是否有用户信息,如果有则显示登录用户信息和退出按钮,我们采取vue的渲染方式
关键代码
Header.html 中
```html
- 尚品汇欢迎您!
- 请登录 免费注册
- {{userInfo.nickName}} 退出
```
### 2.4.3 头部关键字搜索
```html
```
说明:[[${searchParam?.keyword}]],searchParam为搜索列表的搜索对象,如果存在searchParam对象,显示关键字的值
### 2.5.4 头部公共js
```html
```
引用
![img](assets/day10/wps6.jpg)
# 3、用户认证与服务网关整合
## 3.1 实现思路
1. 所有请求都会经过服务网关,服务网关对外暴露服务,不管是api异步请求还是web同步请求都走网关,在网关进行统一用户认证
2. 既然要在网关进行用户认证,网关得知道对哪些url进行认证,所以我们得对url制定规则
3. Web页面同请求(如:*.html),我采取配置白名单的形式,凡是配置在白名单里面的请求都是需要用户认证的(注:也可以采取域名的形式,方式多多)
4. Api接口异步请求的,我们采取url规则匹配,如:/api/** 、/auth /**,如凡是满足该规则的都必须用户认证
所以在Nacos配置列表,修改` server-gateway-dev.yaml`增加需要校验的html访问路径
```yaml
authUrls:
url: trade.html,myOrder.html #,list.html, addCart.html # 用户访问该控制器的时候,会被拦截跳转到登录!
```
## 3.2 在服务网关添加fillter
1. 在`gmall-gateway`模块的pom.xml中增加redis的依赖
```xml
org.springframework.boot
spring-boot-starter-data-redis-reactive
```
2. 由于在`service-user`中存入Redis采用自定义序列化器,固在网关中同样需要配置
```java
package com.atguigu.gmall.gateway.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
@EnableCaching
public class RedisConfig {
@Primary
@Bean
public RedisTemplate