第10章-单点登录.md 39 KB

第10章-单点登录

学习目标:

  • 能够说出主流的认证方案-JWT/Token
  • 搭建用户微服务模块
  • 完成基于Token+Redis实现认证
  • 完成网关统一鉴权

1、单点登录业务介绍

img

早期单一服务器,用户认证。

img

缺点:

  • 单点性能压力,无法扩展

分布式,SSO(single sign on)模式

img

解决 :

  • 用户身份信息独立管理,更好的分布式管理。

  • 可以自己扩展安全策略

  • 跨域不是问题

缺点:

  • 认证服务器访问压力较大。

业务流程图 {用户访问业务时,必须登录的流程}{单点登录的过程}

img

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

2.2.2 启动类

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 version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>gmall-service</artifactId>
        <groupId>com.atguigu.gmall</groupId>
        <version>1.0</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>service-user</artifactId>


    <build>
        <finalName>service-user</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

2.2.4 添加配置文件

在resources目录下新增 bootstrap.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

修改Nacos配置中心:service-user-dev.yaml

image-20230306223213894

2.2.5 生成基础代码

mybatis-plus-code中执行代码生成器代码,将gmall_user数据库中的 user_info,user_address 生成基础代码。

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) {
        // 设置代码生成的位置
        // TODO String projectPath = System.getProperty("user.dir");
        String projectPath = "D:\\code\\workspace2023\\sph\\0825-sph\\gmall-parent\\gmall-service";
        // 设置父模块名称
        String parentModuleName = "com.atguigu.gmall";
        //TODO 设置子模块名称
        String moduleName = "user";
        String subPath = "/service-" + moduleName;
        // 设置数据库连接
        String databaseUrl = "jdbc:mysql://192.168.200.128:3306/gmall_" + moduleName + "?useUnicode=true&useSSL=false&characterEncoding=utf8";
        // 数据库用户名
        String username = "root";
        //TODO 数据库密码
        String password = "123456";
        // 设置表名前缀,例如表为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<String, Object> 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, TemplateType.CONTROLLER);
        // 策略配置
        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();
    }

    /**
     * <p>
     * 读取控制台内容
     * </p>
     */
    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接口地址:

2.2.4.1 控制器

package com.atguigu.gmall.user.controller;

import com.atguigu.gmall.common.result.Result;
import com.atguigu.gmall.user.model.UserInfo;
import com.atguigu.gmall.user.service.UserInfoService;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;
import java.util.Map;

/**
 * @author: atguigu
 * @create: 2023-08-07 11:21
 */
@RestController
@RequestMapping("/api/user")
public class PassportApiController {


    @Autowired
    private UserInfoService userInfoService;


    /**
     * 用户登录
     *
     * @param userInfo
     * @return
     */
    @ApiOperation("用户登录")
    @PostMapping("/passport/login")
    public Result login(@RequestBody UserInfo userInfo) {
        Map<String, Object> mapResult = userInfoService.login(userInfo);
        return Result.ok(mapResult);
    }

    /**
     * 退出系统 前端会将Token设置到请求头“token”中
     *
     * @return
     */
    @GetMapping("/passport/logout")
    public Result logout(@RequestHeader("token") String token) {
        userInfoService.logout(token);
        return Result.ok();
    }
}

2.2.4.2 业务层

业务接口:UserInfoService

package com.atguigu.gmall.user.service;

import com.atguigu.gmall.user.model.UserInfo;
import com.baomidou.mybatisplus.extension.service.IService;

import java.util.Map;

/**
 * 用户表 业务接口类
 * @author atguigu
 * @since 2023-08-07
 */
public interface UserInfoService extends IService<UserInfo> {

    /**
     * 用户登录
     *
     * @param userInfo
     * @return
     */
    Map<String, Object> login(UserInfo userInfo);

    /**
     * 用户退出
     * @param token
     */
    void logout(String token);
}

业务实现类:

package com.atguigu.gmall.user.service.impl;

import com.alibaba.fastjson.JSONObject;
import com.atguigu.gmall.common.constant.RedisConst;
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.atguigu.gmall.user.util.AreaInfo;
import com.atguigu.gmall.user.util.BaiDuService;
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.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

/**
 * 用户表 业务实现类
 *
 * @author atguigu
 * @since 2023-08-07
 */
@Service
public class UserInfoServiceImpl extends ServiceImpl<UserInfoMapper, UserInfo> implements UserInfoService {

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 用户登录
     *
     * @param userInfo loginName可以是账号,邮箱,手机号
     * @param request
     * @return
     */
    @Override
    public Map<String, Object> login(UserInfo userInfo, HttpServletRequest request) {
        //1.根据用户提交账号查询用户记录-可以是登录账号,手机号,或者邮箱均可以
        LambdaQueryWrapper<UserInfo> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(UserInfo::getLoginName, userInfo.getLoginName());
        queryWrapper.or().eq(UserInfo::getEmail, userInfo.getLoginName());
        queryWrapper.or().eq(UserInfo::getPhoneNum, userInfo.getLoginName());
        UserInfo userDB = this.getOne(queryWrapper);
        if (userDB == null) {
            throw new RuntimeException("账户不存在!");
        }
        //2.验证用户密码是否正确
        //2.1 对用户提交密码进行md5加密得到密文
        String userEnterPwd = DigestUtils.md5DigestAsHex(userInfo.getPasswd().getBytes());
        //2.2 跟数据库中用户记录的密文进行比较
        if (!userDB.getPasswd().equals(userEnterPwd)) {
            throw new RuntimeException("密码错误!");
        }

        //3.验证通过,生成令牌 将令牌存入Redis中
        //3.1 采用UUID生成令牌 作为存入Redis 的key
        String token = UUID.randomUUID().toString().replaceAll("-", "");
        //3.2 构建存入Redis中用户信息(用户ID,用户登录设备标识(需要客户端提交),用户登录所在城市-避免令牌被盗取,验证措施)
        JSONObject userObj = new JSONObject();
        userObj.put("id", userDB.getId());
        //获取用户IP地址
        try {
            AreaInfo area = BaiDuService.getArea(IpUtil.getIpAddress(request));
            if (area != null) {
                userObj.put("city", area.getCity());
            }
        } catch (Exception e) {
            log.error("[百度API]获取城市异常:{}", e);
        }
        //3.3 将令牌以及用户信息存入Redis
        String key = RedisConst.USER_KEY_PREFIX + token;
        redisTemplate.opsForValue().set(key, userObj, RedisConst.SKUKEY_TIMEOUT, TimeUnit.SECONDS);

        //4.按照接口文档响应登录业务数据
        Map<String, Object> mapResult = new HashMap<>();
        mapResult.put("token", token);
        mapResult.put("nickName", userDB.getNickName());
        return mapResult;
    }

    /**
     * 用户退出
     *
     * @param token
     */
    @Override
    public void logout(String token) {
        //1.构建用户登录时产生key
        String key = RedisConst.USER_KEY_PREFIX + token;
        //2.删除key
        redisTemplate.delete(key);
    }
}

TIPS:从今日课后资料中获取 百度提供IP获取区域相关 工具类

2.2.4.3 持久层

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<UserInfo> {
}

2.3 配置网关路由

在Nacos配置列表中,server-gateway-dev.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模块添加实现

YAPI接口地址:http://192.168.200.128:3000/project/11/interface/api/803

2.4.1 在web-all 项目中跳转页面

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;

/**
 * @author: atguigu
 * @create: 2023-06-20 11:40
 */
@Controller
public class LoginHtmlController {

    /**
     * 渲染登录页面
     * @param model
     * @param originUrl
     * @return
     */
    @GetMapping("/login.html")
    public String loginHtml(Model model, @RequestParam(value = "originUrl", required = false) String originUrl) {
        model.addAttribute("originUrl", originUrl);
        return "login";
    }
}

2.4.2 登录页面

页面资源: \templates\login1.html

login1.html 没有公共头部信息

login.html 有公共头部信息

img

Html关键代码

<form class="sui-form">
   <div class="input-prepend"><span class="add-on loginname"></span>
      <input id="inputName" type="text" v-model="user.loginName" placeholder="邮箱/用户名/手机号" class="span2 input-xfat">
   </div>
   <div class="input-prepend"><span class="add-on loginpwd"></span>
      <input id="inputPassword" type="password" v-model="user.passwd" placeholder="请输入密码" class="span2 input-xfat">
   </div>
   <div class="setting">
      <label class="checkbox inline">
         <input name="m1" type="checkbox" value="2" checked="">
         自动登录
      </label>
      <span class="forget">忘记密码?</span>
   </div>
   <div class="logined">
      <a class="sui-btn btn-block btn-xlarge btn-danger" href="javascript:" @click="submitLogin()">登&nbsp;&nbsp;录</a>
   </div>
</form>

<script src="/js/api/login.js"></script>
<script th:inline="javascript">
   var item = new Vue({
      el: '#profile',

      data: {
            originUrl: [[${originUrl}]],
            user: {
                loginName: '',
                passwd: ''
            }
      },

      created() {
      },

      methods: {
            submitLogin() {
                login.login(this.user).then(response => {
                    
                    if (response.data.code == 200) {
                        //把token存在cookie中、也可以放在localStorage中
                        auth.setToken(response.data.data.token)
                        auth.setUserInfo(JSON.stringify(response.data.data))

                        console.log("originUrl:"+this.originUrl);
                        if(this.originUrl == ''){
                            window.location.href="http://www.gmall.com/index.html"
                            return ;
                        } else {
                            window.location.href = decodeURIComponent(this.originUrl)
                  }
                    } else {
                  alert(response.data.data.message)
               }

                })
            }
        }
   })
</script>

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),关键代码

<div id="nav-bottom" th:fragment="header">
    <!--顶部-->
	<div class="nav-top" id="header">
		...
	</div>
</div>

2,在其他页面引用头部代码块

<div th:include="common/header :: header"></div>

2.5.2 头部登录状态处理

思路:登录成功后我们将用户信息写入了cookie,所以我们判断cookie中是否有用户信息,如果有则显示登录用户信息和退出按钮,我们采取vue的渲染方式

关键代码

Header.html 中

<ul class="fl">
    <li class="f-item">尚品汇欢迎您!</li>
    <li  v-if="userInfo.nickName == ''" class="f-item">请<span><a href="javascript:" @click="login()">登录</a></span> <span><a href="#">免费注册</a></span></li>
    <li  v-if="userInfo.nickName != ''" class="f-item"><span>{{userInfo.nickName}}</span> <span><a href="javascript:" @click="logout()">退出</a></span></li>
</ul>

<script type="text/javascript" src="/js/plugins/jquery/jquery.min.js"></script>
<script type="text/javascript" src="/js/plugins/jquery.cookie.js"></script>
<script src="/js/plugins/vue.js"></script>
<script src="/js/plugins/axios.js"></script>
<script src="/js/auth.js"></script>
<script src="/js/request.js"></script>
<script src="/js/api/login.js"></script>
<script th:inline="javascript">
    var item = new Vue({
        el: '#header',

        data: {
            userInfo: {
                nickName: '',
                name: ''
            }
        },

        created() {
            this.showInfo()
        },

        methods: {
            showInfo() {
                // debugger
                if(auth.getUserInfo()) {
                    this.userInfo = auth.getUserInfo()
                    console.log("--------"+this.userInfo.nickName)
                }
            },

            
            logout() {
                //debugger
                login.logout().then(response => {
                    console.log("已退出")
                    auth.removeToken()
                    auth.removeUserInfo()

                    //跳转页面
                    window.location.href = "/"
                })
            }
        }
    })
</script>

2.4.3 头部关键字搜索

<div class="input-append">
    <input id="keyword" type="text" v-model="keyword" class="input-error input-xxlarge" />
    <button class="sui-btn btn-xlarge btn-danger" @click="search()" type="button">搜索</button>
</div>
<script th:inline="javascript">
    var item = new Vue({
        el: '#header',

        data: {
            keyword: [[${searchParam?.keyword}]],
            userInfo: {
                nickName: '',
                name: ''
            }
        },

        created() {
            this.showInfo()
        },

        methods: {
            showInfo() {
                // debugger
                if(auth.getUserInfo()) {
                    this.userInfo = auth.getUserInfo()
                    console.log("--------"+this.userInfo.nickName)
                }
            },

            search() {
                if(this.keyword == null) this.keyword = ''
                window.location.href = 'http://list.gmall.com/search.html?keyword=' + this.keyword
            },

            login() {
                window.location.href = 'http://passport.gmall.com/login.html?originUrl='+window.location.href
            },

            logout() {
                //debugger
                login.logout().then(response => {
                    console.log("已退出")
                    auth.removeToken()
                    auth.removeUserInfo()

                    //跳转页面
                    window.location.href = "/"
                })
            }
        }
    })
</script>

说明:[[${searchParam?.keyword}]],searchParam为搜索列表的搜索对象,如果存在searchParam对象,显示关键字的值

2.5.4 头部公共js

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>

<div th:fragment="head">
    <script type="text/javascript" src="/js/plugins/jquery/jquery.min.js"></script>
    <script type="text/javascript" src="/js/plugins/jquery.cookie.js"></script>
    <script src="/js/plugins/vue.js"></script>
    <script src="/js/plugins/axios.js"></script>
    <script src="/js/auth.js"></script>
    <script src="/js/request.js"></script>
</div>
</body>
</html>

引用

img

3、用户认证与服务网关整合

3.1 实现思路

  1. 所有请求都会经过服务网关,服务网关对外暴露服务,不管是api异步请求还是web同步请求都走网关,在网关进行统一用户认证

  2. 既然要在网关进行用户认证,网关得知道对哪些url进行认证,所以我们得对url制定规则

  3. Web页面同步请求(如:*.html),我采取配置白名单的形式,凡是配置在白名单里面的请求都是需要用户认证的(注:也可以采取域名的形式,方式多多)

  4. Api接口异步请求的,我们采取url规则匹配,如:/api/** 、/auth /**,"/inner/"如凡是满足该规则的都必须用户认证

所以在Nacos配置列表,修改server-gateway-dev.yaml增加需要校验的html访问路径

authUrls:
  url: trade.html,myOrder.html #,list.html, addCart.html # 用户访问该控制器的时候,会被拦截跳转到登录!

3.2 在服务网关添加fillter

  1. gmall-gateway模块的pom.xml中增加redis的依赖

    <dependency>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
    </dependency>
    <!-- spring2.X集成redis所需common-pool2-->
    <dependency>
       <groupId>org.apache.commons</groupId>
       <artifactId>commons-pool2</artifactId>
       <version>2.6.0</version>
    </dependency>
    
  2. gmall-gateway模块中配置RedisTemplate采用自定义序列化器

    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<Object, Object> RedisTemplate(RedisConnectionFactory RedisConnectionFactory) {
           RedisTemplate<Object, Object> RedisTemplate = new RedisTemplate<>();
           RedisTemplate.setConnectionFactory(RedisConnectionFactory);
       
           //使用Jackson2JsonRedisSerialize 替换默认序列化(默认采用的是JDK序列化) 对存储的对象进行JSON序列化
           Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
           ObjectMapper objectMapper = new ObjectMapper();
           objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
           objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
           jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
       
           // 序列化key value
           RedisTemplate.setKeySerializer(new StringRedisSerializer());
           RedisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
           RedisTemplate.setHashKeySerializer(new StringRedisSerializer());
           RedisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
       
           RedisTemplate.afterPropertiesSet();
           return RedisTemplate;
       }
       
    }
    
  3. gmall-gateway 项目中添加一个全局过滤器

    package com.atguigu.gmall.gateway.filter;
       
    import com.alibaba.fastjson.JSON;
    import com.alibaba.fastjson.JSONObject;
    import com.atguigu.gmall.common.result.Result;
    import com.atguigu.gmall.common.result.ResultCodeEnum;
    import lombok.extern.slf4j.Slf4j;
    import org.apache.commons.lang.StringUtils;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.cloud.gateway.filter.GatewayFilterChain;
    import org.springframework.cloud.gateway.filter.GlobalFilter;
    import org.springframework.core.Ordered;
    import org.springframework.core.io.buffer.DataBuffer;
    import org.springframework.data.redis.core.RedisTemplate;
    import org.springframework.http.HttpCookie;
    import org.springframework.http.HttpHeaders;
    import org.springframework.http.HttpStatus;
    import org.springframework.http.server.reactive.ServerHttpRequest;
    import org.springframework.http.server.reactive.ServerHttpResponse;
    import org.springframework.stereotype.Component;
    import org.springframework.util.AntPathMatcher;
    import org.springframework.util.CollectionUtils;
    import org.springframework.web.server.ServerWebExchange;
    import reactor.core.publisher.Mono;
       
    import java.util.Collection;
    import java.util.List;
       
    /**
    * 全局过滤器:对经过网关所有请求,对所有路由生效。
    *
    * @author: atguigu
    * @create: 2023-08-07 14:44
    */
    @Slf4j
    @Component
    public class AuthFilter implements GlobalFilter, Ordered {
       
       
       /**
        * 需要登录才能访问静态页同步请求
        */
       @Value("${authUrls.url}")
       private List<String> authUrls;
       
       
       AntPathMatcher antPathMatcher = new AntPathMatcher();
       
       @Autowired
       private RedisTemplate redisTemplate;
       
       
       /**
        * 过滤器业务逻辑
        *
        * @param exchange 封装请求,响应对象
        * @param chain
        * @return
        */
       @Override
       public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
           ServerHttpRequest request = exchange.getRequest();
           ServerHttpResponse response = exchange.getResponse();
           log.info("[网关过滤器] 统一鉴权过滤器执行了。");
       
           //1. 将静态资源请求放行 例如:css样式 js文件 img图片
           //1.1 先获取用户访问地址
           String path = request.getURI().getPath();
           //1.2 通过路径匹配匹配静态资源规则,匹配成功直接放行
           if (antPathMatcher.match("/js/**", path) ||
                   antPathMatcher.match("/css/**", path) ||
                   antPathMatcher.match("/img/**", path)) {
               return chain.filter(exchange);
           }
       
           //2. 对于某些Ajax异步请求接口无论登录与否都无法进行访问 例如:/inner/**  响应提示错误信息
           if (antPathMatcher.match("/**/inner/**", path)) {
               return outError(response, ResultCodeEnum.ILLEGAL_REQUEST);
           }
       
           //3. 在网关中获取到用户提交token令牌,根据令牌查询Redis获取用户ID
           String userId = this.getUserIdFromRedis(request);
       
           //4. 如果从Redis获取用户ID为空-用户未登录  用户访问同步静态页需要认证才能访问;用户访问Ajax异步请求需要认证才能访问
           if (StringUtils.isBlank(userId)) {
               //4.1 情况一:访问需要登录才能访问静态页同步请求,跳转到登录页面
               if (!CollectionUtils.isEmpty(authUrls)) {
                   for (String authUrl : authUrls) {
                       if (antPathMatcher.match("/" + authUrl, path)) {
                           //跳转到登录页面 //http状态码设置303
                           response.setStatusCode(HttpStatus.SEE_OTHER);
                           //重定向页面  浏览器会重定向到指定页面
                           response.getHeaders().add(HttpHeaders.LOCATION, "http://passport.gmall.com/login.html?originUrl=" + request.getURI());
                           //停止路由转发
                           return response.setComplete();
                       }
                   }
               }
               //4.2 情况二:访问Ajax异步请求 地址中包含/auth/登录才能访问,响应错误信息,未登录不允许访问
               if (antPathMatcher.match("/**/auth/**", path)) {
                   return outError(response, ResultCodeEnum.LOGIN_AUTH);
               }
           } else {
               //5. 说明用户登录,路由转发请求前,将当前用户ID透传到下游目标微服务中
               request.mutate().header("userId", userId);
           }
           return chain.filter(exchange);
       }
       
       
       /**
        * 通过客户端提交Token获取用户ID,客户端提交Token途径两种:
        * * 通过请求头提交token
        * * 通过Cookie提交token
        *
        * @param request
        * @return
        */
       private String getUserIdFromRedis(ServerHttpRequest request) {
           String token = "";
           //1.先获取token 优先从Cookie中获取token
           HttpCookie tokenCookie = request.getCookies().getFirst("token");
           if (tokenCookie != null) {
               token = tokenCookie.getValue();
           } else {
               //2.如果Cookie中未提交,再从请求头中获取
               token = request.getHeaders().getFirst("token");
           }
           //3.拼接用户登录key 查询Redis获取用户ID
           if (StringUtils.isNotBlank(token)) {
               String key = "user:" + token;
               JSONObject jsonObj = (JSONObject) redisTemplate.opsForValue().get(key);
               if (jsonObj != null) {
                   return jsonObj.getString("id");
               }
           }
           return null;
       }
       
       
       /**
        * 过滤器执行顺序,值越小优先级越高
        *
        * @return
        */
       @Override
       public int getOrder() {
           return 0;
       }
       
       
       /**
        * 用来给前端响应错误提示信息
        *
        * @param response
        * @param resultCodeEnum
        * @return
        */
       private Mono<Void> outError(ServerHttpResponse response, ResultCodeEnum resultCodeEnum) {
           //1.准备响应结果对象,转为JSON对象
           Result<Object> result = Result.build(null, resultCodeEnum);
           String resultString = JSON.toJSONString(result);
       
           //2.响应结果给客户端
           //2.1 设置http状态码
           response.setStatusCode(HttpStatus.UNAUTHORIZED);
           //2.2 通过响应头设置响应数据格式-json
           response.getHeaders().add("content-type", "application/json;charset=utf-8");
           DataBuffer wrap = response.bufferFactory().wrap(resultString.getBytes());
           //2.4 网关将响应数据返回给客户端
           return response.writeWith(Mono.just(wrap));
       }
       
       
       public static void main(String[] args) {
           //String path = "/js/plugins/jquery/jquery.min.js";
           //String path = "/api/product/inner/getCategoryView/61";
           String path = "/trade.html";
           //路径匹配器
           AntPathMatcher antPathMatcher = new AntPathMatcher();
           boolean match = antPathMatcher.match("/trade.html", path);
           System.out.println(match);
       }
    }
    

3.3 在服务网关中判断用户登录状态

在网关中如何获取用户信息:

1、从cookie中获取(如:web同步请求)

2、从header头信息中获取(如:异步请求)

如何判断用户信息合法:

登录时我们返回用户token,在服务网关中获取到token后,我在到redis中去查看用户id,如果用户id存在,则token合法,否则不合法,同时校验ip,防止token被盗用。

3.3.1 取用户信息

/**
 * 通过客户端提交Token获取用户ID,客户端提交Token途径两种:
 * * 通过请求头提交token
 * * 通过Cookie提交token
 *
 * @param request
 * @return
 */
private String getUserIdFromRedis(ServerHttpRequest request) {
    String token = "";
    //1.先获取token 优先从Cookie中获取token
    HttpCookie tokenCookie = request.getCookies().getFirst("token");
    if (tokenCookie != null) {
        token = tokenCookie.getValue();
    } else {
        //2.如果Cookie中未提交,再从请求头中获取
        token = request.getHeaders().getFirst("token");
    }
    //3.拼接用户登录key 查询Redis获取用户ID
    if (StringUtils.isNotBlank(token)) {
        String key = "user:" + token;
        JSONObject jsonObj = (JSONObject) redisTemplate.opsForValue().get(key);
        if (jsonObj != null) {
            return jsonObj.getString("id");
        }
    }
    return null;
}

3.3.2 输出信息out 方法

/**
 * 用来给前端响应错误提示信息
 *
 * @param response
 * @param resultCodeEnum
 * @return
 */
private Mono<Void> outError(ServerHttpResponse response, ResultCodeEnum resultCodeEnum) {
    //1.准备响应结果对象,转为JSON对象
    Result<Object> result = Result.build(null, resultCodeEnum);
    String resultString = JSON.toJSONString(result);

    //2.响应结果给客户端
    //2.1 设置http状态码
    response.setStatusCode(HttpStatus.UNAUTHORIZED);
    //2.2 通过响应头设置响应数据格式-json
    response.getHeaders().add("content-type", "application/json;charset=utf-8");
    DataBuffer wrap = response.bufferFactory().wrap(resultString.getBytes());
    //2.3 网关结束响应-不再路由转发
    //return response.setComplete();
    //2.4 网关将响应数据返回给客户端
    return response.writeWith(Mono.just(wrap));
}

3.3.3 测试

  1. 通过网关访问内部接口,则不能访问!

http://localhost/api/product/inner/getSkuInfo/17

img

  1. 测试登录权限

测试一:

未登录 :http://localhost/api/product/auth/hello

img

登录完成之后继续测试!

登录:http://localhost/api/product/auth/hello

img

使用localhost访问,你登录或者不登录,都会提示未登录!

测试二:

用户在未登录情况下测试:

http://item.gmall.com/api/product/auth/hello

img

在上面的访问链接的时候,如果用户登录了,那么还会继续提示未登录!

img

404 表示资源没有!没有提示未登录!

原因:

测试一:访问资源的时候,没有获取到userId

测试二:访问资源的时候,获取到了userId

因为:我们登录成功的时候,将token放入了cookie中。在放入cookie的时候,我们给cookie 设置了一个作用域。

return $.cookie('token', token, {domain: 'gmall.com', expires: 7, path: '/'})

测试一:使用的域名是localhost

测试二:使用item.gmall.com 包含gmall.com

所以测试二是正确的!以后我们访问的时候,不会通过localhost访问,都是通过域名访问的!

  1. 验证Url 访问的是控制器

未登录直接访问:会弹出登录页面

http://list.gmall.com/list.html

  1. 登录之后,然后在访问

会显示查询结果!

http://list.gmall.com/list.html