在此特别感谢黑马程序员提供的课程

写在最前

模块需求分析

什么是认证授权

  • 截至目前,项目已经完成了课程发布功能,课程发布后用户通过在线学习页面点播视频进行学习。如何去记录学生的学习过程呢?要想掌握学生的学习情况就需要知道用户的身份信息,记录哪个用户在什么时间学习什么课程,如果用户要购买课程也需要知道用户的身份信息。所以,去管理学生的学习过程最基本的要实现用户的身份认证。
  • 认证授权模块实现平台所有用户的身份认证和用户授权功能
    • 什么是用户身份认证?
    • 用户身份认证即当用户访问系统资源时,系统要求验证用户的身份信息,身份合法方可继续访问
    • 常见的用户身份认证表现形式有
      • 用户名密码登录
      • 微信扫码登录等
  • 项目包括学生、学习机构的老师、平台运营人员三类用户。
  • 不管哪一类用户在访问项目受保护的资源时,都需要进行身份认证,例如
    • 发布课程操作:需要学习机构的老师首先登录系统成功,然后再执行发布课程操作
    • 创建订单操作:需要学生用户首先登录系统成功,才可以创建订单
      • 什么是用户授权?
      • 用户认证通过后去访问系统的资源,系统会判断用户是否拥有访问资源的权限,只允许访问有权限的系统资源,没有权限的资源将无法访问,这个过程叫用户授权。
      • 例如用户去发布课程,系统首先进行用户身份认证,认证通过后继续判断用户是否有发布课程的权限
        • 如果没有权限,则拒绝继续访问系统
        • 如果有权限,则继续发布课程

业务流程

统一认证

  • 项目包括学生、学习机构的老师、平台运营人员三类用户,三类用户将使用统一的认证入口
  • 用户输入账号密码提交认证,认证通过后继续操作
  • 认证通过由认证服务想用户颁发令牌,相当于访问系统的通行证,用户拿着令牌去访问系统的资源

单点登录

  • 本项目基于微服务架构构建,微服务包括:内容管理服务、媒资管理服务、系统管理服务等。
  • 为了提高用户的体验性,用户只需要依次认证,便可以在多个拥有访问权限的系统中访问,这个功能叫单点登录
    • 单点登录(Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。

第三方认证

  • 为了提高用户体验,很多网站都具有扫码登录的功能,例如微信扫码登录、QQ扫码登录等
  • 扫码登录的好处是用户不用输入账号密码,操作简便,而且有利于用户信息的共享。
  • 互联网的优势就是资源共享,用户也是一种资源,对于一个新网站,如果让用户去注册是很困难的,如果提供了微信扫码登录,将省去用户的注册成本,是一种非常有效的推广方式。
  • 微信扫码登录其中的原理正是使用了第三方认证,如下图

Spring Security认证

Spring Security介绍

  • 认证功能几乎是每个项目都要具备的功能,并且它与业务无关,市面上有很多认证框架,如Apache Shiro、CAS、Spring Security等
  • 本项目是基于Spring Cloud技术构建,Spring Security是spring家族的一份子,且和Spring Cloud集成的很好,所以本项目采用Spring Security作为认证服务的技术框架
  • Spring Security是一个功能强大且可高度定制的身份验证和访问控制框架,它是一个专注于为Java应用程序提供身份验证和授权的框架
  • 项目主页:https://spring.io/projects/spring-security
  • SpringCloud Security:https://spring.io/projects/spring-cloud-security

认证授权入门

  • 下面我们使用Spring Security框架,快速构建认证授权功能体系

    1. 部署认证服务工程
      • 拷贝黑马提供的xuecheng-plus-auth工程到自己的项目根目录下
      • 此工程是一个普通的SpringBoot工程,可以连接数据库
      • 此工程不具备认证授权功能
    2. 创建数据库
      • 创建users数据库
      • 导入黑马提供的xcplus_users.sql脚本
      • 在nacos中新增auth-service-dev.yaml
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        server:
        servlet:
        context-path: /auth
        port: 53070
        spring:
        datasource:
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://localhost:3306/xc_users?serverTimezone=UTC&userUnicode=true&useSSL=false&
        username: root
        password: mysql
  • 初始工程自带了一个Controller类,如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    @Slf4j
    @RestController
    public class LoginController {

    @Autowired
    XcUserMapper userMapper;

    @RequestMapping("/login-success")
    public String loginSuccess() {
    return "登录成功";
    }

    @RequestMapping("/user/{id}")
    public XcUser getuser(@PathVariable("id") String id) {
    XcUser xcUser = userMapper.selectById(id);
    return xcUser;
    }

    @RequestMapping("/r/r1")
    public String r1() {
    return "访问r1资源";
    }

    @RequestMapping("/r/r2")
    public String r2() {
    return "访问r2资源";
    }
    }
  • 启动工程,访问localhost:53070/auth/r/r1localhost:53070/auth/user/52,可以访问到数据,则表明此工程部署成功

认证测试

  • 下面向SpringBoot工程集成Spring Security
  • 向pom.xml中加入Spring Security所需的依赖
    1
    2
    3
    4
    5
    6
    7
    8
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-security</artifactId>
    </dependency>
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-oauth2</artifactId>
    </dependency>
  • 重启工程,访问localhost:53070/auth/r/r1,自动进入/login页面,/login页面是由Spring Security提供的
  • 那么账号和密码是什么呢?我们需要进行安全配置,创建WebSecurityConfig配置类,继承WebSecurityConfigurerAdapter
    1. 配置用户信息
      1
      2
      3
      4
      5
      6
      7
      8
      9
      @Bean
      public UserDetailsService userDetailsService() {
      // 1. 配置用户信息服务,暂时将用户信息存储在内存,后面会改成从数据库查
      InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
      // 2. 创建用户信息, Kyle的权限是p1,Lucy的权限是p2
      manager.createUser(User.withUsername("Kyle").password("123").authorities("p1").build());
      manager.createUser(User.withUsername("Lucy").password("456").authorities("p2").build());
      return manager;
      }
    2. 密码方式,暂时采用明文的方式
      1
      2
      3
      4
      @Bean
      public PasswordEncoder passwordEncoder() {
      return NoOpPasswordEncoder.getInstance();
      }
    3. 安全拦截机制,/r/**开头的请求需要认证
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      protected void configure(HttpSecurity http) throws Exception {
      http.authorizeRequests()
      .antMatchers("/r/**")
      .authenticated()
      .anyRequest().permitAll()
      .and()
      .formLogin()
      .successForwardUrl("/login-success");
      http.logout().logoutUrl("/logout");
      }
      • 配置说明:
        1. 通过 authorizeRequests() 方法来配置请求授权规则。
        2. 使用 antMatchers() 方法指定需要进行访问控制的 URL 路径模式。在这里,/r/** 表示所有以 /r/ 开头的 URL 都需要进行授权访问。
        3. 使用 authenticated() 方法指定需要进行身份验证的请求。
        4. 使用 anyRequest() 方法配置除了 /r/** 以外的所有请求都不需要进行身份验证。
        5. 使用 permitAll() 方法表示任何用户都可以访问不需要进行身份验证的 URL
        6. 使用 formLogin() 方法配置登录页表单认证,其中 successForwardUrl() 方法指定登录成功后的跳转页面。
        7. 使用 logout() 方法配置退出登录,其中 logoutUrl() 方法指定退出登录的 URL
  • 完整代码如下
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    @EnableWebSecurity
    @EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)
    public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    public UserDetailsService userDetailsService() {
    // 1. 配置用户信息服务,暂时将用户信息存储在内存,后面会改成从数据库查
    InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
    // 2. 创建用户信息, Kyle的权限是p1,Lucy的权限是p2
    manager.createUser(User.withUsername("Kyle").password("123").authorities("p1").build());
    manager.createUser(User.withUsername("Lucy").password("456").authorities("p2").build());
    return manager;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
    return NoOpPasswordEncoder.getInstance();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
    .antMatchers("/r/**")
    .authenticated()
    .anyRequest().permitAll()
    .and()
    .formLogin()
    .successForwardUrl("/login-success");
    http.logout().logoutUrl("/logout");
    }
    }
  • 重启工程
    • 访问localhost:53070/auth/user/52可以正常访问
    • 访问localhost:53070/auth/r/r1会被拦截,显示登录页面

授权测试

  • 用户认证通过去访问系统资源时,Spring Security进行授权控制,判断用户是否有该资源的访问权限
    • 如果有则继续访问
    • 如果没有则拒绝访问
  • 下面测试授权功能
    1. 配置用户拥有哪些权限,在WebSecurityConfig中我们已经配置过了
      1
      2
      3
      4
      5
      6
      7
      8
      9
      @Bean
      public UserDetailsService userDetailsService() {
      // 1. 配置用户信息服务,暂时将用户信息存储在内存,后面会改成从数据库查
      InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
      // 2. 创建用户信息, Kyle的权限是p1,Lucy的权限是p2
      manager.createUser(User.withUsername("Kyle").password("123").authorities("p1").build());
      manager.createUser(User.withUsername("Lucy").password("456").authorities("p2").build());
      return manager;
      }
    2. 指定资源与权限的关系
      • 什么是系统的资源?
      • 比如:查询一个用户的信息,用户信息就是系统的资源,要访问资源需要通过URL,所以我们在Controller中定义的每个HTTP的接口就是访问资源的接口。
    • 下面我们在Controller中配置/r/r1需要p1权限,/r/r2需要p2权限
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
          @RequestMapping("/r/r1")
      + @PreAuthorize("hasAnyAuthority('p1')")
      public String r1() {
      return "访问r1资源";
      }

      @RequestMapping("/r/r2")
      + @PreAuthorize("hasAuthority('p2')")
      public String r2() {
      return "访问r2资源";
      }
    • 现在重启工程
      • Kyle只有p1权限,所以无法访问/r/r2,访问会报403错误
      • Lucy只有p2权限,所以无法访问/r/r1,访问会报403错误
        • 注意:如果访问上不加@PreAuthorize注解,此方法没有授权控制
  • 整个授权的过程如图所示

工作原理

  • 通过测试认证和授权两个功能,我们了解了Spring Security的基本使用方法,下面我们来了解一下它的工作流程
  • Spring Security所解决的问题是安全访问控制,而安全访问控制功能其实就是对所有进入系统的请求进行拦截,校验每个请求是否能够访问到它所期望的资源。
  • 根据我们之前学过的知识,可以通过Filter或AOP等技术来实现,Spring Security对Web资源的保护是靠Filter实现的,所以我们从Filter来入手,逐步深入Spring Security原理
  • 当初始化Spring Security时,会创建一个名为SpringSecurityFilterChain的Servlet过滤器,类型为 org.springframework.security.web.FilterChainProxy,它实现了javax.servlet.Filter,因此外部的请求会经过此类,下图是Spring Security过虑器链结构图:
  • FilterChainProxy是一个代理,真正起作用的是FilterChainProxy中的SecurityFilterChain所包含的各个Filter,同时这些Filter作为BeanSpring管理,它们是Spring Security的核心,各有各的职责,同时它们并不直接处理用户的认证,也不直接处理用户的授权,而是将它们交给了认证管理器(AuthenticationManager)决策管理器(AccessDecisionManager)进行处理
  • Spring Security功能的实现主要是由一系列过滤器链相互配合完成的
  • 下面介绍过滤器链中主要的几个过滤器及其作用
    • SecurityContextPresistenceFilter:这个Filter是整个拦截过程的入口和出口(也就是第一个和最后一个拦截器),会在请求开始时从配置好的SecurityContextRepository中获取SecurityContext,然后把它设置给SecurityContextHolder,在请求完成后,将SecurityContextRepository持有的SecurityContext再保存到配置好的SecurityContextRepository,同时清楚SecurityContextHolder所持有的SecurityContext
    • UsernamePasswordAuthenticationFilter:用于处理来自表单提交的认证,该表单必须提供对应的用户名和密码,其内部还有登录成功或失败后进行处理的AuthenticationSuccessHandler和AuthenticationFailureHandler,这些都可以根据需求做相关改变
    • FilterSecurityInterceptor是用于保护web资源的,使用AccessDecisionManager对当前用户进行授权访问
    • ExeptionTranslationFilter能够捕获来自FilterChain所有的异常,并进行处理。但是他只会处理两类异常:AuthenticationExceptionAccessDeniedException,其他的异常它会继续抛出
  • SpringSecurity的执行流程如下
    1. 用户提交用户名、密码被SecurityFilterChain中的UsernamePasswordAuthenticationFilter过滤器获取到,封装为请求Authentication,通常情况下是UsernamePasswordAuthenticationToken这个实现类
    2. 然后过滤器将Authentication提交至认证管理器(AuthenticationManager)进行认证
    3. 认证成功后,AuthenticationManager身份管理器返回一个被填充满了信息(权限信息、身份信息、细节信息等,但密码通常会被移除)的Authentication实例
    4. SecurityContextHolder将第三步填充了信息的Authentication通过SecurityContextHolder.getContext().setAuthentication()方法,设置到其中。
    5. AuthenticationManager接口是认证相关的核心接口,也是发起认证的出发点,它的实现类为ProviderManager,而Spring Security支持多种认证方式,因此ProviderManager维护着一个List<AuthenticationProvider>列表,存放多种认证方式,最终实际的认证工作是AuthenticationProvider完成的。Web表单对应的AuthenticationProvider的实现类为DaoAuthenticationProvider,它的内部又维护着一个UserDetailsService负责UserDetails的获取。最终AuthenticationProvider将UserDetails填充至Authentication

什么是OAuth2

OAuth2认证流程

  • 前面我们提到的微信扫码认证,是一种第三方认证方式,这种认证方式是基于OAuth2协议实现的
  • OAuth协议为用户资源的授权提供了一个安全的、开放而又简易的标准。
  • 同时,任何第三方都可以使用OAuth认证服务,任何服务提供商都可以实现自身的OAuth认证服务,因而OAuth是开放的。
  • 业界提供了OAuth的多种实现,如PHP、JavaScript、Java、Ruby等各种语言开发包,大大节约了程序员的时间,因而OAuth是简易的。
  • 互联网很多服务如Open API,很多大公司如Google、Yahoo、Microsoft等都提供了OAuth认证服务,这些都足以说明OAuth标准逐渐成为开放资源授权的标准
  • OAuth协议目前发展到2.0版本,1.0版本过于复杂,2.0版本已得到广泛应用
  • 下面分析一个OAuth2认证的例子,微信认证扫码登录的过程:
  • 具体流程如下
    1. 用户点击微信扫码登录,微信扫码的目的是通过微信认证登录目标网站,目标网站需要从微信获取当前用户的身份信息才会让当前用户在目标网站登录成功
      • 首先搞清楚几个概念
        • 资源:用户信息,在微信中存储
        • 资源拥有者:用户是用户信息资源的拥有者
        • 认证服务:微信负责认证当前用户的身份,负责为客户端颁发令牌
        • 客户端:客户端会携带令牌请求微信获取用户信息
    2. 用户授权网站访问用户信息
      • 资源拥有者扫描二维码,表示资源拥有者请求微信进行认证,微信认证通过向用户手机返回授权页面(让你确认登录)
      • 询问用户是否授权目标网站访问自己在微信的用户信息,用户点击(确认登录)表示同意授权,微信认证服务器会颁发一个授权码给目标网站
      • 只有资源拥有者同意,微信才允许目标网站访问资源
    3. 目标网站获取到授权码
    4. 携带授权码请求微信认证服务器,申请令牌(此交互过程用户看不到)
    5. 微信认证服务器想目标网站响应令牌(此交互过程用户看不到)
    6. 目标网站携带令牌请求微信服务器获取用户的基本信息
    7. 资源服务器返回受保护资源,即用户信息
    8. 目标网站接收到用户信息,此时用户在目标网站登录成功
  • OAuth 2.0认证流程如下
  • OAuth 2.0包括以下角色
    1. 客户端:本身不存储资源,需要通过资源拥有者的授权去请求资源服务器的资源,例如:手机客户端、浏览器等
    2. 资源拥有者:通常为用户,也可以是应用程序,即该资源的拥有者
    3. 授权服务器(认证服务器):认证服务器对资源拥有者进行认证,还会对客户端进行认证并颁发令牌
    4. 资源服务器:存储资源的服务器
  • 上图中
    • A表示:客户端请求资源拥有者授权
    • B表示:资源拥有者授权客户端,即用户授权目标网站访问自己的用户信息
    • C表示:目标网站携带授权码请求认证
    • D表示:认证通过,颁发令牌
    • E表示:目标网站携带令牌请求资源服务器,获取资源
    • F表示:资源服务器校验令牌通过后,提供受保护的资源

OAuth2在本项目的应用

  • OAuth2是一个标准的开放的授权协议,应用程序可以根据自己的需求去使用
  • 本项目使用OAuth2实现如下目标
    1. 学成在线访问第三方系统的资源
      • 本项目要接入微信扫码登录,所以本项目要是用OAuth2协议访问微信中的用户信息
    2. 外部系统访问学成在线的资源
      • 同样当第三方系统想要访问学成在线网站的资源,也可以基于OAuth2协议来访问用户信息
    3. 学成在线前端(客户端)访问学成在线微服务的资源
      • 本项目是前后端分离架构,前端访问微服务资源也可以基于OAuth2协议

OAuth2的授权模式

  • Spring Security支持OAuth2认证,OAuth2提供授权码模式、密码模式、简化模式、客户端模式等四种授权模式。前面举的微信扫码登录的例子就是基于授权码模式。
  • 这四种模式中,授权码模式和密码模式应用较多,这里使用Spring Security演示授权码模式、密码模式。
## 授权码模式
  • OAuth2的几个授权模式是根据不同的应用场景以不同的方式去获取令牌,最终目的是要获取认证服务颁发的令牌,然后通过令牌去获取资源
  • 授权码模式简单理解就是使用授权码去获取令牌,要想获取令牌,首先要获取授权码,授权码的获取需要资源拥有者亲自授权同意才可以获取
  • 下图是授权码模式的交互图
    1. 用户打开浏览器
    2. 通过浏览器访问客户端
    3. 通过浏览器想认证服务请求授权(用户扫描二维码)
      • 请求授权时会携带客户端的URL,此URL为下发授权码的重定向地址
    4. 认证服务向资源拥有者返回授权页面
    5. 资源拥有者亲自授权同意(用户点击同意登录
    6. 通过浏览器向认证服务发送授权同意
    7. 认证服务向客户端地址重定向,并携带授权码
    8. 客户端收到授权码
    9. 客户端携带授权码向认证服务申请令牌
    10. 认证服务向客户端颁发令牌
## 授权码模式测试
  • 要想测试授权码模式,首先要配置授权服务,即上图中的认证服务器,需要配置授权服务及令牌策略
  • 拷贝黑马提供的AuthorizationServer.java、TokenConfig.java到config包下

    1. Authorization使用@EnableAuthorizationServer注解标识并继承AuthorizationServerConfigurerAdapter来配置OAuth2.0授权服务器
      1
      2
      3
      4
      5
      @Configuration
      @EnableAuthorizationServer
      public class AuthorizationServer extends AuthorizationServerConfigurerAdapter {
      ···
      }
      • AuthorizationServerConfigurerAdapter要求配置以下几个类
        • AuthorizationServerSecurityConfigurer:用来配置令牌端点的安全约束
        • ClientDetailsServiceConfigurer:用来配置客户端详情服务
          • 随便一个客户端都可以随便接入到它的认证服务吗?答案是否定的,服务提供商会给批准接入的客户端一个身份,用于接入时的凭据,有客户端标识和客户端秘钥,在这里配置批准接入的客户端的详情信息
        • AuthorizationServerEndpointsConfigurer:用来配置令牌(token)的访问端点和令牌服务(token services)
          1
          2
          3
          4
          5
          6
          7
          8
          9
          10
          11
          12
          13
          public class AuthorizationServerConfigurerAdapter implements AuthorizationServerConfigurer {
          public AuthorizationServerConfigurerAdapter() {
          }

          public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
          }

          public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
          }

          public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
          }
          }
    2. TokenConfig为令牌策略配置类
      • 暂时使用InMemoryTokenStore在内存存储令牌,令牌的有效期等信息配置如下
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        @Configuration
        public class TokenConfig {

        @Autowired
        TokenStore tokenStore;

        @Bean
        public TokenStore tokenStore() {
        //使用内存存储令牌(普通令牌)
        return new InMemoryTokenStore();
        }

        @Bean(name = "authorizationServerTokenServicesCustom")
        public AuthorizationServerTokenServices tokenService() {
        DefaultTokenServices service = new DefaultTokenServices();
        service.setSupportRefreshToken(true);//支持刷新令牌
        service.setTokenStore(tokenStore);//令牌存储策略
        service.setAccessTokenValiditySeconds(7200); // 令牌默认有效期2小时
        service.setRefreshTokenValiditySeconds(259200); // 刷新令牌默认有效期3天
        return service;
        }
        }
    3. 配置认证管理Bean
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      @EnableWebSecurity
      @EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)
      public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
      @Bean
      public AuthenticationManager authenticationManagerBean() throws Exception {
      return super.authenticationManagerBean();
      }

      ···

      }
  • 重启认证服务

    1. get请求获取授权码,地址:http:///auth/oauth/authorize?client_id=XcWebApp&response_type=code&scope=all&redirect_uri=http://localhost/
      • 参数列表如下
        • client_id:客户端准入标志
        • response_type:授权码模式固定为code
        • scope:客户端权限
        • redirect_uri:跳转uri,当授权码申请成功后门会跳转到此地址,并在后面带上code参数(授权码)
      • 输入账号Kyle、密码123登录成功,输入http:///auth/oauth/authorize?client_id=XcWebApp&response_type=code&scope=all&redirect_uri=http://localhost/ 显示授权页面
      • 授权XcWebApp访问自己受保护的资源,选择同意
    2. 请求成功,重定向至http://localhost/?code=授权码, 例如:http://localhost/?code=H7J61Z
    3. 使用HttpClient工具POST申请令牌:
      • 参数列表如下
        • client_id:客户端准入标识
        • client_secret:客户端秘钥
        • grant_type:授权类型,填写authorization_code,表示授权码模式
        • code:授权码,就是刚刚获取的授权码。
          • 注意:授权码只使用一次就无效了,需要重新申请
        • redirect_uri:申请授权码时的跳转uri,一定要和申请授权码时使用的redirect_uri一致
      • HttpClient脚本如下
        1
        2
        ###### 授权码模式
        POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=authorization_code&code=W34ttV&redirect_uri=http://localhost/
      • 申请令牌成功如下所示
        1
        2
        3
        4
        5
        6
        7
        {
        "access_token": "c75d121f-7430-4cf9-9ff6-eb25e5c01ca0",
        "token_type": "bearer",
        "refresh_token": "b950149e-40e8-47f3-9f1d-1c6df74dd69f",
        "expires_in": 7199,
        "scope": "all"
        }
      • 说明
        1. access_token:访问令牌,用于访问资源使用
        2. token_type:bearer是在RFC6750中定义的一种token类型,在携带令牌访问资源时,需要在head中加入bearer空格令牌内容
        3. refresh_token:当令牌快过期时使用刷新令牌,可以再次生成令牌
        4. expires_in:过期时间
        5. scope:令牌的权限范围,服务端可以根据令牌的权限范围去对令牌授权
## 密码模式
  • 密码模式相较于授权码模式简单,授权码模式需要借助浏览器供用户亲自授权,密码模式不用借助浏览器,如图
    1. 资源提供者提供账号和密码
    2. 客户端向认证服务申请令牌,请求中携带账号和密码
    3. 认证服务校验账号和密码正确,颁发令牌
  • 开始测试
    1. POST请求获取令牌
      1
      {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=Kyle&password=123
    • 参数列表如下
      • client_id:客户端准入标识
      • client_secret:客户端秘钥
      • grant_type:授权类型,填写password标识密码模式
      • username:资源拥有者用户名
      • password:资源拥有者密码
    1. 授权服务器将令牌发送给client,使用HttpClient进行测试
      1
      2
      ###### 密码模式
      POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=Kyle&password=123
    • 返回示例
      1
      2
      3
      4
      5
      6
      7
      {
      "access_token": "c75d121f-7430-4cf9-9ff6-eb25e5c01ca0",
      "token_type": "bearer",
      "refresh_token": "b950149e-40e8-47f3-9f1d-1c6df74dd69f",
      "expires_in": 4687,
      "scope": "all"
      }
    • 这种模式十分简单,但是却意味着直接将用户敏感信息泄露给了client,因此说明这种模式只能用于client是我们自己开发的情况下
## 本项目的应用方式
  • 通过演示授权码模式和密码模式,授权码模式适合客户端和认证服务非同一个系统的情况,所以本项目采用授权码模式完成微信扫码认证,采用密码模式作为前端请求微服务的认证方式

JWT

普通令牌问题

  • 客户端申请到令牌,接下来客户端携带令牌去访问资源,到资源服务器会校验令牌的合法性。
  • 资源服务器如何校验令牌的合法性?这里以OAuth2的密码模式为例进行说明
  • 前三步获取令牌我们已经在代码中完成了,这里从第四步开始说明
    1. 客户端携带令牌访问资源服务,获取资源
    2. 资源服务远程请求认证服务校验令牌的合法性
    3. 如果令牌合法,资源服务想客户端返回资源
  • 这里存在一个问题:校验令牌需要远程请求认证服务,客户端每次访问都会远程校验,执行性能低
  • 如果能够让资源服务自己校验令牌的合法性,就可以省去远程请求认证服务的成本,提高了性能,如下图
  • 如何解决上面的问题,实现资源服务自行校验令牌呢?
    • 令牌采用JWT格式即可解决上面的问题,用户认证后会得到一个JWT令牌,JWT令牌中已经包括了用户相关的信息,客户端只需要携带JWT访问资源服务,资源服务根据事先约定的算法自行完成令牌校验,无需每次都请求认证服务完成授权

什么是JWT

  • 什么是JWT?
  • 官网:https://jwt.io/
  • Json Web Token(JWT)是一种使用Json格式传递数据的网络令牌技术,它是一个开放的行业标准(RFC 7519),它定义了一种简介的、自包含的协议格式,用于在通信双方传递Json对象,传递的对象经过数字签名可以被验证和信任,它可以是应用HMAC算法或使用RSA的公钥/私钥来签名,防止内容篡改
  • 使用JWT可以实现无状态认证。什么是无状态认证
  • 传统的基于Session的方式是有状态认证,用户登录成功,将用户的身份信息存储在服务端,这样加大了服务端的存储压力,并且这种方式不适合在分布式系统中应用
  • 如图,当用户访问应用服务,每个应用服务都会去服务器查看Session信息,如果没有Session,则认证用户没有登录,此时会重新认证,而解决这个问题的颁发是Session复制黏贴
  • 如果是基于令牌技术,在分布式系统中实现认证,服务端不用存储Session,可以将用户身份信息存储在令牌中,用户认证通过后认证服务颁发令牌给用户,用户将令牌存储在客户端,去访问应用服务时携带令牌去访问,服务端从JWT解析出用户信息,这个过程就是无状态认证
  • JWT令牌的优点
    1. JWT基于JSON,非常方便解析
    2. 可以在令牌中自定义丰富的内容,易扩展
    3. 通过非对称加密算法及数字签名技术,JWT防篡改,安全性高
    4. 资源服务使用JWT可不依赖认证服务即可完成授权
  • 缺点
    1. JWT令牌较长,占存储空间比较大,下面是一个JWT令牌的示例
      1
      eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzMSJdLCJ1c2VyX25hbWUiOiJ6aGFuZ3NhbiIsInNjb3BlIjpbImFsbCJdLCJleHAiOjE2NjQyNTQ2NzIsImF1dGhvcml0aWVzIjpbInAxIl0sImp0aSI6Ijg4OTEyYjJkLTVkMDUtNGMxNC1iYmMzLWZkZTk5NzdmZWJjNiIsImNsaWVudF9pZCI6ImMxIn0.wkDBL7roLrvdBG2oGnXeoXq-zZRgE9IVV2nxd-ez_oA
      • JWT令牌由三部分组成,每部分中间使用点(.)分隔,例如xxxx.yyyyyy.zzzzzzz
        1. Header:第一部分是头部
          • 头部包括令牌的类型(即JWT)及使用的哈希算法(如HMAC、SHA256或RSA),一个例子如下
            1
            2
            3
            4
            {
            "alg": "HS256",
            "typ": "JWT"
            }
          • 将上面的内容使用Base64Url编码,得到一个字符串就是JWT令牌的第一部分
        2. Payload:第二部分是负载,内容也是一个Json对象
          • 它是存放有效信息的地方,它可以存放JWT提供的现成字段,如iss(签发者)、exp(过期时间戳)、sub(面向的用户)等,也可以自定义字段
          • 此部分不建议存放敏感信息,因为此部分可以解码还原原始内容
          • 最后将第二部分负载使用Base64Url编码,得到一个字符串就是JWT令牌的第二部分
            1
            2
            3
            4
            5
            {
            "sub": "1234567890",
            "name": "456",
            "admin": true
            }
        3. Sugbature:第三部分是签名,此部分用于防止JWT内容被篡改。
          • 这个部分使用Base64Url将前两部分进行编码,编码后使用点(.)连接组成字符串,最后使用Header中声明的签名算法进行签名
            1
            2
            3
            4
            HMACSHA256(
            base64UrlEncode(header) + "." +
            base64UrlEncode(payload),
            secret)
          • base64UrlEncode(header):JWT令牌的第一部分
          • base64UrlEncode(payload):JWT令牌的第二部分
  • 为什么JWT可以防止篡改?
  • 第三部分使用签名算法对第一部分和第二部分的内容进行签名,常见的签名算法是HS526,常见的还有MD5、SHA等,签名算法需要使用密钥进行签名,密钥不对外公开,并且签名是不可逆的,如果第三方更改了内容,那么服务器验证前面就会失败,要想保证签名正确,必须保证内容、密钥与签名前一致

  • 从上图中可以看出,认证服务和资源服务使用相同的密钥,这叫对称加密,对称加密效率高,如果一旦密钥泄露可以伪造JWT令牌
  • JWT还可以使用非对称加密,认证服务自己保留私钥,将公钥下发给受信任的客户端、资源服务,公钥和私钥是配对的,成对的公钥和私钥才可以正常加密、解密,非对称加密效率低,但相比较于对称加密更加安全

测试生成JWT令牌

  • 在认证服务中配置JWT令牌服务,即可实现生成JWT格式的令牌
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    @Configuration
    public class TokenConfig {

    private String SIGNING_KEY = "mq123";

    @Autowired
    TokenStore tokenStore;

    @Autowired
    private JwtAccessTokenConverter accessTokenConverter;

    @Bean
    public TokenStore tokenStore() {
    return new JwtTokenStore(accessTokenConverter());
    }

    @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
    JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
    converter.setSigningKey(SIGNING_KEY);
    return converter;
    }

    //令牌管理服务
    @Bean(name = "authorizationServerTokenServicesCustom")
    public AuthorizationServerTokenServices tokenService() {
    DefaultTokenServices service = new DefaultTokenServices();
    service.setSupportRefreshToken(true);//支持刷新令牌
    service.setTokenStore(tokenStore);//令牌存储策略

    TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
    tokenEnhancerChain.setTokenEnhancers(Arrays.asList(accessTokenConverter));
    service.setTokenEnhancer(tokenEnhancerChain);

    service.setAccessTokenValiditySeconds(7200); // 令牌默认有效期2小时
    service.setRefreshTokenValiditySeconds(259200); // 刷新令牌默认有效期3天
    return service;
    }
    }
  • 重启认证服务,通过HttpClient通过密码模式申请令牌
    1
    2
    #### 密码模式
    POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=Kyle&password=123
  • 生成的JWT示例如下
    1
    2
    3
    4
    5
    6
    7
    8
    {
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsieHVlY2hlbmctcGx1cyJdLCJ1c2VyX25hbWUiOiJLeWxlIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTY3ODQyNzQ5NiwiYXV0aG9yaXRpZXMiOlsicDEiXSwianRpIjoiY2IyOTI0ZjYtOGZiOS00N2ViLThjNGEtMWFmMjkzZWU4NTg4IiwiY2xpZW50X2lkIjoiWGNXZWJBcHAifQ.aVZOsHBEuowof41HgV2auyDrRh9ZiNfwn4qoQWjla7o",
    "token_type": "bearer",
    "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsieHVlY2hlbmctcGx1cyJdLCJ1c2VyX25hbWUiOiJLeWxlIiwic2NvcGUiOlsiYWxsIl0sImF0aSI6ImNiMjkyNGY2LThmYjktNDdlYi04YzRhLTFhZjI5M2VlODU4OCIsImV4cCI6MTY3ODY3OTQ5NiwiYXV0aG9yaXRpZXMiOlsicDEiXSwianRpIjoiNjFhNWRmOGItZTc3ZS00YmVkLWE3OTQtZTlmMjJkM2FmMTYyIiwiY2xpZW50X2lkIjoiWGNXZWJBcHAifQ.JqEL9V4Yn8tWYtvH46wtbAgJQ1dEoseuWyQhDdZNveo",
    "expires_in": 7199,
    "scope": "all",
    "jti": "cb2924f6-8fb9-47eb-8c4a-1af293ee8588"
    }
    1. access_token:生成的JWT令牌,用于访问资源使用
    2. token_type:bearer是在RFC6750中定义的一种token类型,在携带JWT访问资源时,需要在head中加入bearer jwt令牌内容
    3. refresh_token:当JWT令牌快过期时使用刷新令牌可以再次生成JWT令牌
    4. expires_in:过期时间(秒)
    5. scope:令牌的权限范围,服务端可以根据令牌的权限范围去对令牌授权
    6. jti:令牌的唯一表示
  • 我们可以通过check_token接口校验jwt令牌
    1
    2
    #### 校验JWT令牌
    POST {{auth_host}}/auth/oauth/check_token?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsieHVlY2hlbmctcGx1cyJdLCJ1c2VyX25hbWUiOiJLeWxlIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTY3ODQyOTg5MywiYXV0aG9yaXRpZXMiOlsicDEiXSwianRpIjoiMzNhMzg4YWMtNzNmYS00ODBmLWEzMWUtOTdmOTJmMjBkNWZkIiwiY2xpZW50X2lkIjoiWGNXZWJBcHAifQ.cTcfIzL2avSp2XEsPvGU2IoJ060ooln1hARZCrvCxp4
  • 响应示例如下
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    {
    "aud": [
    "xuecheng-plus"
    ],
    "user_name": "Kyle",
    "scope": [
    "all"
    ],
    "active": true,
    "exp": 1678429893,
    "authorities": [
    "p1"
    ],
    "jti": "33a388ac-73fa-480f-a31e-97f92f20d5fd",
    "client_id": "XcWebApp"
    }

测试资源服务校验令牌

  • 拿到了JWT令牌下一步就要携带令牌去访问资源服务中的资源,本项目各个微服务就是资源服务,例如:内容管理服务,当客户端申请到JWT令牌,携带JWT去内容管理服务查询课程信息,此时内容管理服务需要对JWT进行校验,只有JWT合法才可以继续访问,如下图
  1. 在内容管理服务的content-api中添加依赖
    1
    2
    3
    4
    5
    6
    7
    8
    9
    <!--认证相关-->
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-security</artifactId>
    </dependency>
    <dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-oauth2</artifactId>
    </dependency>
  2. 在内容管理服务的content-api中添加TokenConfig配置类
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    @Configuration
    public class TokenConfig {
    private String SIGNING_KEY = "mq123";

    @Bean
    public JwtAccessTokenConverter accessTokenConverter(){
    JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
    converter.setSigningKey(SIGNING_KEY);
    return converter;
    }

    @Bean
    public TokenStore tokenStore() {
    return new JwtTokenStore(accessTokenConverter());
    }
    }
  3. 添加资源服务配置类ResourceServerConfig
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    @Configuration
    @EnableResourceServer
    @EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)
    public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    public static final String RESOURCE_ID = "xuecheng-plus";

    @Autowired
    TokenStore tokenStore;

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) {
    resources.resourceId(RESOURCE_ID)
    .tokenStore(tokenStore)
    .stateless(true);
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
    http.csrf().disable() // 禁用 CSRF 保护
    .authorizeRequests() //配置对请求的授权策略
    .antMatchers("/r/**", "/course/**").authenticated() // 指定 "/r/" 和 "/course/" 这两个路径需要进行身份认证才能访问。
    .anyRequest().permitAll(); // 允许所有其他请求(除了上面指定的路径之外)都可以被访问,不需要进行身份认证。
    }
    }
  • 重启内容管理服务,使用HttpClient进行测试
    1. 访问根据课程id查询课程接口
      1
      2
      3
      ###### 根据课程id查询课程基本信息
      GET {{content_host}}/content/course/22
      Content-Type: application/json
      • 返回
        1
        2
        3
        4
        {
        "error": "unauthorized",
        "error_description": "Full authentication is required to access this resource"
        }
      • 从返回信息可知,当前没有认证
    2. 携带JWT令牌访问接口
      • 首先申请令牌
        1
        2
        ###### 密码模式
        POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=Kyle&password=123
      • 携带JWT令牌访问资源服务地址
        1
        2
        GET {{content_host}}/content/course/160
        Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsieHVlY2hlbmctcGx1cyJdLCJ1c2VyX25hbWUiOiJLeWxlIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTY3ODQzOTMwOSwiYXV0aG9yaXRpZXMiOlsicDEiXSwianRpIjoiNTAxNDNiZTItOGM3ZC00MmUzLWEwNDMtMTQwMGQ5NWQ5MmZiIiwiY2xpZW50X2lkIjoiWGNXZWJBcHAifQ.o3nWLeRkJncEnnZ0egFmBpyC8Keq-L8IY6k0Uc0a96c
      • 携带JWT令牌,且JWT令牌正确,则正常访问资源服务的内容
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        {
        "id": 160,
        "companyId": 1232141425,
        "companyName": null,
        "name": "猫片",
        "users": "不知道啊不知道啊不知道啊a a a ",
        "tags": "",
        "mt": "1-5",
        "st": "1-5-4",
        "grade": "204003",
        "teachmode": "200002",
        "description": null,
        "pic": "/mediafiles/2023/03/03/76ac562669dc346992af9dd039060e7b.jpg",
        "createDate": "2023-03-02 17:17:07",
        "changeDate": "2023-03-05 11:09:31",
        "createPeople": null,
        "changePeople": null,
        "auditStatus": "203002",
        "status": "203001",
        "charge": "201000",
        "price": 0.0,
        "originalPrice": null,
        "qq": "",
        "wechat": "",
        "phone": "",
        "validDays": 365,
        "mtName": "人工智能",
        "stName": "计算机视觉"
        }
      • 如果JWT令牌错误,咋会报令牌无效
        1
        2
        3
        4
        {
        "error": "invalid_token",
        "error_description": "Cannot convert access token to JSON"
        }

测试获取用户身份

  • JWT令牌中记录了用户身份信息,当客户端携带JWT访问资源服务,资源服务验签通过后,将两部分内容还原,即可取出用户的身份信息,并将用户身份信息放在了SecurityContextHolder上下文,SecurityContext与当前线程进行绑定,方便获取用户身份
  • 继续以查询课程接口为例,进入查询课程接口的代码中,添加获取用户身份的代码
    1
    2
    3
    4
    5
    6
    7
        @ApiOperation("根据课程id查询课程基础信息")
    @GetMapping("/course/{courseId}")
    public CourseBaseInfoDto getCourseBaseById(@PathVariable Long courseId) {
    + Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    + System.out.println("当前用户身份为:" + principal);
    return courseBaseInfoService.getCourseBaseInfo(courseId);
    }
  • 重启内容管理服务,使用HttpClient测试接口,查看控制台是否会输出用户身份
    1
    当前用户身份为:Kyle

网关鉴权

什么是网关鉴权

  • 到目前为止,测试通过了认证服务颁发的JWT令牌,客户端携带JWT访问资源服务,资源服务会对JWT的合法性进行验证,如下图
  • 仔细观察此图,遗漏了本项目架构中非常重要的组件:网关,加上网关并完善后如下图所示
  • 所有访问微服务的请求都要经过网关,在网关进行用户身份的认证,可以将很多非法的请求拦截到微服务以外,这叫做网关鉴权
  • 下面需要明确网关鉴权的职责
    1. 网站白名单维护:针对不用认证的URL全部放行
    2. 校验JWT的合法性:除了白名单剩下的就是需要认证的请求,网关需要验证JWT的合法性,JWT合法则说明用户身份合法,否则说明身份不合法,拒绝继续访问
  • 网关负责授权吗?
  • 网关不负责授权,对请求的授权操作在各个微服务进行,因为微服务最清楚用户有哪些权限访问哪些接口

实现网关就鉴权

  • 下面实现网关鉴权
    1. 在网关工程添加依赖
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-security</artifactId>
      </dependency>
      <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-oauth2</artifactId>
      </dependency>
      <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      </dependency>
      <dependency>
      <groupId>com.alibaba</groupId>
      <artifactId>fastjson</artifactId>
      </dependency>
    2. 拷贝黑马提供的网关鉴权配置类到gateway-api的config包下
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      @Component
      @Slf4j
      public class GatewayAuthFilter implements GlobalFilter, Ordered {
      //白名单
      private static List<String> whitelist = null;

      static {
      //加载白名单
      try (
      InputStream resourceAsStream = GatewayAuthFilter.class.getResourceAsStream("/security-whitelist.properties");
      ) {
      Properties properties = new Properties();
      properties.load(resourceAsStream);
      Set<String> strings = properties.stringPropertyNames();
      whitelist = new ArrayList<>(strings);

      } catch (Exception e) {
      log.error("加载/security-whitelist.properties出错:{}", e.getMessage());
      e.printStackTrace();
      }
      }

      @Autowired
      private TokenStore tokenStore;

      @Override
      public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
      String requestUrl = exchange.getRequest().getPath().value();
      AntPathMatcher pathMatcher = new AntPathMatcher();
      //白名单放行
      for (String url : whitelist) {
      if (pathMatcher.match(url, requestUrl)) {
      return chain.filter(exchange);
      }
      }
      //检查token是否存在
      String token = getToken(exchange);
      if (StringUtils.isBlank(token)) {
      return buildReturnMono("没有认证", exchange);
      }
      //判断是否是有效的token
      OAuth2AccessToken oAuth2AccessToken;
      try {
      oAuth2AccessToken = tokenStore.readAccessToken(token);
      boolean expired = oAuth2AccessToken.isExpired();
      if (expired) {
      return buildReturnMono("认证令牌已过期", exchange);
      }
      return chain.filter(exchange);
      } catch (InvalidTokenException e) {
      log.info("认证令牌无效: {}", token);
      return buildReturnMono("认证令牌无效", exchange);
      }
      }

      /**
      * 获取token
      */
      private String getToken(ServerWebExchange exchange) {
      String tokenStr = exchange.getRequest().getHeaders().getFirst("Authorization");
      if (StringUtils.isBlank(tokenStr)) {
      return null;
      }
      String token = tokenStr.split(" ")[1];
      if (StringUtils.isBlank(token)) {
      return null;
      }
      return token;
      }

      private Mono<Void> buildReturnMono(String error, ServerWebExchange exchange) {
      ServerHttpResponse response = exchange.getResponse();
      String jsonString = JSON.toJSONString(new RestErrorResponse(error));
      byte[] bits = jsonString.getBytes(StandardCharsets.UTF_8);
      DataBuffer buffer = response.bufferFactory().wrap(bits);
      response.setStatusCode(HttpStatus.UNAUTHORIZED);
      response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
      return response.writeWith(Mono.just(buffer));
      }

      @Override
      public int getOrder() {
      return 0;
      }
      }

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      public class RestErrorResponse implements Serializable {

      private String errMessage;

      public RestErrorResponse(String errMessage){
      this.errMessage= errMessage;
      }

      public String getErrMessage() {
      return errMessage;
      }

      public void setErrMessage(String errMessage) {
      this.errMessage = errMessage;
      }
      }
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      @EnableWebFluxSecurity
      @Configuration
      public class SecurityConfig {
      //安全拦截配置
      @Bean
      public SecurityWebFilterChain webFluxSecurityFilterChain(ServerHttpSecurity http) {
      return http.authorizeExchange()
      .pathMatchers("/**").permitAll()
      .anyExchange().authenticated()
      .and().csrf().disable().build();
      }
      }
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      @Configuration
      public class TokenConfig {

      String SIGNING_KEY = "mq123";

      @Bean
      public TokenStore tokenStore() {
      return new JwtTokenStore(accessTokenConverter());
      }

      @Bean
      public JwtAccessTokenConverter accessTokenConverter() {
      JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
      converter.setSigningKey(SIGNING_KEY);
      return converter;
      }
      }
    3. 配置白名单文件security-whitelist.properties
      1
      2
      3
      /auth/**=认证地址
      /content/open/**=内容管理公开放文件接口
      /media/open/**=媒资管理公开访问接口
  • 重启网关工程,进行测试
    1. 申请令牌
      1
      2
      ###### 密码模式
      POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=Kyle&password=123
    2. 通过网关访问资源服务(将端口换为网关端口)
      1
      2
      GET {{gateway_host}}/content/course/40
      Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsieHVlY2hlbmctcGx1cyJdLCJ1c2VyX25hbWUiOiJLeWxlIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTY3ODQ0MTU3MiwiYXV0aG9yaXRpZXMiOlsicDEiXSwianRpIjoiZWJkNDkzNjgtMjc4My00OTAxLWE5MTMtZGM5ZjUyYTg5ZWQ2IiwiY2xpZW50X2lkIjoiWGNXZWJBcHAifQ.6V9OaU5FutGp9Ol2QzaP57HVxe9w1d5S0Y5TdWLDxzw
    • 当token正确时可以正常访问资源服务,token验证失败时,会返回token失效
      1
      2
      3
      {
      "errMessage": "认证令牌无效"
      }
      • 注意:网关鉴权功能调试通过后,由于目前还没有开发认证功能,前端请求网关的URL不在白名单时,会出现没有认证错误,所以暂时在白名单中添加全部放行配置,待认证功能开发完成后,再屏蔽全部放行配置
        1
        2
        3
        4
        /**=暂时全部放开
        /auth/**=认证地址
        /content/open/**=内容管理公开访问接口
        /media/open/**=媒资管理公开访问接口

用户认证

需求分析

  • 至此我们了解了使用Spring Security进行认证授权的过程,本届实现用户认证功能。目前各大网站的认证方式也是十分丰富:账号密码认证、手机验证码认证、扫码认证等,所以本项目也要支持多种认证方式

连接用户中心数据库

连接数据库认证

  • 到目前为止,我们的用户认证流程如下
  • 认证所需要的用户信息存储在xc_user库中,之前我们是将用户信息硬编码,放在内存中的,现在我们要从数据库来查询用户信息来登录
  • 如何使用Spring Security连接数据库认证?
  • 用户提交账号和密码由DaoAuthenticationProvider调用UserDetailsService的loadUserByUsername()方法获取UserDetails用户信息
  • UserDetailsService是一个接口
    1
    2
    3
    public interface UserDetailsService {
    UserDetails loadUserByUsername(String var1) throws UsernameNotFoundException;
    }
  • UserDetails使用户信息接口
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();

    String getPassword();

    String getUsername();

    boolean isAccountNonExpired();

    boolean isAccountNonLocked();

    boolean isCredentialsNonExpired();

    boolean isEnabled();
    }
  • 我们只要实现UserDetailsService接口查询数据库得到用户信息返回UserDetails类型的用户信息即可
    • 首先屏蔽原来定义的UserDetailsService
      1
      2
      3
      4
      5
      6
      7
      8
      9
      //   @Bean
      // public UserDetailsService userDetailsService() {
      // // 1. 配置用户信息服务,暂时将用户信息存储在内存,后面会改成从数据库查
      // InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
      // // 2. 创建用户信息, Kyle的权限是p1,Lucy的权限是p2
      // manager.createUser(User.withUsername("Kyle").password("123").authorities("p1").build());
      // manager.createUser(User.withUsername("Lucy").password("456").authorities("p2").build());
      // return manager;
      // }
    • 下面自定义UserDetailsService
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      @Service
      public class UserDetailsImpl implements UserDetailsService {
      @Autowired
      XcUserMapper xcUserMapper;

      /**
      *
      * @param name 用户输入的登录账号
      * @return UserDetails
      * @throws UsernameNotFoundException
      */
      @Override
      public UserDetails loadUserByUsername(String name) throws UsernameNotFoundException {
      // 没别的意思,只是变量名看着舒服
      // 根据username去XcUser表中查询对应的用户信息
      XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, name));
      // 返回NULL表示用户不存在,SpringSecurity会帮我们处理,框架抛出异常用户不存在
      if (user == null) {
      return null;
      }
      // 取出数据库存储的密码
      String password = user.getPassword();
      //如果查到了用户拿到正确的密码,最终封装成一个UserDetails对象给spring security框架返回,由框架进行密码比对
      return User.withUsername(user.getUsername()).password(password).authorities("test").build();
      }
      }
  • 写到这里,我们需要清楚框架调用loadUserByUsername()方法拿到用户信息后是如何执行的
  • 数据库中的密码是加密过的,用户输入的时候是明文,我们需要修改密码格式器PasswordEncoder
    • 原来使用的是NoOpPasswordEncoder,它是通过明文的方式比较的密码
    • 现在我们需要修改为BCryptPasswordEncoder,它是将用户输入的密码编码为BCrypt格式与数据库中的密码进行比对
      1
      2
      3
      4
      5
      @Bean
      public PasswordEncoder passwordEncoder() {
      - return NoOpPasswordEncoder.getInstance();
      + return new BCryptPasswordEncoder();
      }
    • 我们通过测试代码测试BCryptPasswordEncoder,如下
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      public static void main(String[] args) {
      String password = "123456";
      BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
      for (int i = 0; i < 5; i++) {
      // 每个计算出的Hash值都不一样
      String encodePsw = encoder.encode(password);
      // 虽然Hash值不一样,但是校验是可以通过的
      System.out.println("转换后密码:" + encodePsw + "比对情况:" + encoder.matches(password, encodePsw));
      }
      }

      // 转换后密码:$2a$10$6hbvtCtgcISvbBHJ.UnhPO1io7StF.ySPkmAvzO/efvNmHVVJZOeK比对情况:true
      // 转换后密码:$2a$10$ufYW9qXSAk0N201B/wCR7uGrzygawnwXtyL2vKpDLAOCOkF33sGnK比对情况:true
      // 转换后密码:$2a$10$DEaVxYHakIE/kDvAU4eC7OZ7c9kqKBJedClVxDPnYH.zwuZvCRnzm比对情况:true
      // 转换后密码:$2a$10$s2qgaKGgULYQ7tce2u6TIeHopap4HqfyghJYu1vdDZ2WcNk70ykFe比对情况:true
      // 转换后密码:$2a$10$XQaQJIfXyd/UvMHC..uBNuDXNVrZHnEGn.tW0oSB6WVjdsZLFpkGq比对情况:true
  • 修改数据库中的密码为BCrype格式,并且记录明文密码,稍后申请令牌是需要
  • 修改密码编码方式还需要客户端的密钥更改为BCrypt格式
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @Override
    public void configure(ClientDetailsServiceConfigurer clients)
    throws Exception {
    clients.inMemory()// 使用in-memory存储
    .withClient("XcWebApp")// client_id
    .secret(new BCryptPasswordEncoder().encode("XcWebApp"))//客户端密钥
    .resourceIds("xuecheng-plus")//资源列表
    .authorizedGrantTypes("authorization_code", "password", "client_credentials", "implicit", "refresh_token")// 该client允许的授权类型authorization_code,password,refresh_token,implicit,client_credentials
    .scopes("all")// 允许的授权范围
    .autoApprove(false)//false跳转到授权页面
    .redirectUris("http://localhost/");//客户端接收授权码的重定向地址
    }
  • 现在重启认证服务,使用HttpClient进行测试
    1
    2
    ###### 密码模式
    POST localhost:53070/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=Kyle&password=111111
    • 输入正确的账号密码,申请令牌成功
      1
      2
      3
      4
      5
      6
      7
      8
      {
      "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsieHVlY2hlbmctcGx1cyJdLCJ1c2VyX25hbWUiOiJLeWxlIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTY3ODQ1MTUxNiwiYXV0aG9yaXRpZXMiOlsidGVzdCJdLCJqdGkiOiJkOWUwYjU0ZS03Zjg4LTQ2NjAtYjFlZS04ZWQzYjYzZmQwNjMiLCJjbGllbnRfaWQiOiJYY1dlYkFwcCJ9.NaS3hmpDtX3zkXvnZWoo9ROEWgYeA6GoxBzy_lOxzvA",
      "token_type": "bearer",
      "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsieHVlY2hlbmctcGx1cyJdLCJ1c2VyX25hbWUiOiJLeWxlIiwic2NvcGUiOlsiYWxsIl0sImF0aSI6ImQ5ZTBiNTRlLTdmODgtNDY2MC1iMWVlLThlZDNiNjNmZDA2MyIsImV4cCI6MTY3ODcwMzUxNiwiYXV0aG9yaXRpZXMiOlsidGVzdCJdLCJqdGkiOiI5YjE4OTUwOS1iYmEzLTRkMTctYTNkNC05OWQwZGI5NjU0MDAiLCJjbGllbnRfaWQiOiJYY1dlYkFwcCJ9.548w5CdQiIU5_k1qRBjzM-PMBqy-XX3zr17tQS6g6CM",
      "expires_in": 7199,
      "scope": "all",
      "jti": "d9e0b54e-7f88-4660-b1ee-8ed3b63fd063"
      }
    • 输入错误的密码,申请令牌失败
      1
      2
      3
      4
      {
      "error": "invalid_grant",
      "error_description": "用户名或密码错误"
      }

扩展用户身份信息

  • 用户表中存储了用户的账号、手机号、email、昵称、QQ等信息,UserDetails接口只返回了username、password等信息
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();

    String getPassword();

    String getUsername();

    boolean isAccountNonExpired();

    boolean isAccountNonLocked();

    boolean isCredentialsNonExpired();

    boolean isEnabled();
    }
  • 我们需要扩展用户身份信息,在JWT令牌中存储用户的昵称、头像、QQ等信息
    • 如何扩展Spring Security的用户身份信息呢?
    • 在认证阶段DaoAuthenticationProvider会调用UserDetailsService查询用户的信息,这里是可以获取到齐全的用户信息。
    • 由于JWT令牌中用户身份信息来源于UserDetails,UserDetails中仅定义了username为用户的身份信息,这里有两个思路
      1. 扩展UserDetails,时期包括更多的自定义属性
      2. 扩展username的内容,例如存入Json数据作为username的内容
    • 相较而言,方案2比较简单,而且也不用破坏UserDetails的结构,这里采用方案二
  • 修改UserDetailsImpl如下
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    @Service
    public class UserDetailsImpl implements UserDetailsService {
    @Autowired
    XcUserMapper xcUserMapper;

    /**
    *
    * @param s 用户输入的登录账号
    * @return UserDetails
    * @throws UsernameNotFoundException
    */
    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
    // 没别的意思,只是变量名看着舒服
    String name = s;
    // 根据username去XcUser表中查询对应的用户信息
    XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, name));
    // 返回空表示用户不存在,SpringSecurity会帮我们处理
    if (user == null) {
    return null;
    }
    // 取出数据库存储的密码
    String password = user.getPassword();
    + // 用户敏感信息不要设置
    + user.setPassword(null);
    + String userString = JSON.toJSONString(user);
    // 创建UserDetails对象,并返回,注意这里的authorities必须指定
    - return User.withUsername(user.getUsername()).password(password).authorities("test").build();
    + return User.withUsername(userString).password(password).authorities("test").build();
    }
    }
  • 重启认证服务,重新生成令牌
    1
    2
    #### 密码模式
    POST localhost:53070/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=Kyle&password=111111
  • 校验令牌
    1
    2
    #### 校验JWT令牌
    POST localhost:53070/auth/oauth/check_token?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsieHVlY2hlbmctcGx1cyJdLCJ1c2VyX25hbWUiOiJ7XCJjb21wYW55SWRcIjpcIjEyMzIxNDE0MjVcIixcImNyZWF0ZVRpbWVcIjpcIjIwMjItMDktMjhUMDg6MzI6MDNcIixcImlkXCI6XCI1MlwiLFwibmFtZVwiOlwiS2lraVwiLFwicGFzc3dvcmRcIjpcIiQyYSQxMCQwcHQ3V2xmVGJuUERUY1d0cC8uMk11NUNUWHZvaG5OUWhSNjI4cXE0Um9LU2MwZEdBZEVnbVwiLFwic2V4XCI6XCIxXCIsXCJzdGF0dXNcIjpcIlwiLFwidXNlcm5hbWVcIjpcIkt5bGVcIixcInV0eXBlXCI6XCIxMDEwMDJcIn0iLCJzY29wZSI6WyJhbGwiXSwiZXhwIjoxNjc4NDUyMzU0LCJhdXRob3JpdGllcyI6WyJ0ZXN0Il0sImp0aSI6Ijc2MDc0MDI4LTBiM2MtNDQ4Mi1hN2Y0LTc1NDI3ZTA2OTFjMSIsImNsaWVudF9pZCI6IlhjV2ViQXBwIn0._GKfGE2s5k0n6VC4_RKQrzdzydWY-WtX3Q_Hc4DxQ1g
  • 响应示例如下
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    {
    "aud": [
    "xuecheng-plus"
    ],
    "user_name": "{\"companyId\":\"1232141425\",\"createTime\":\"2022-09-28T08:32:03\",\"id\":\"52\",\"name\":\"Kiki\",\"password\":\"$2a$10$0pt7WlfTbnPDTcWtp/.2Mu5CTXvohnNQhR628qq4RoKSc0dGAdEgm\",\"sex\":\"1\",\"status\":\"\",\"username\":\"Kyle\",\"utype\":\"101002\"}",
    "scope": [
    "all"
    ],
    "active": true,
    "exp": 1678452354,
    "authorities": [
    "test"
    ],
    "jti": "76074028-0b3c-4482-a7f4-75427e0691c1",
    "client_id": "XcWebApp"
    }
  • user_name存储了用户信息的JSON格式,在资源服务中就可以取出该JSON格式的内容,转换为用户对象去使用

资源服务获取用户身份

  • 下面编写一个工具类在各个微服务中去使用,获取当前登录用户的对象

    • 我们可以通过SecurityContextHolder获取user_name,然后将其转换为XcUser对象
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      @Slf4j
      public class SecurityUtil {
      public static XcUser getUser() {
      try {
      Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
      if (principal instanceof String) {
      String userJson = principal.toString();
      XcUser xcUser = JSON.parseObject(userJson, XcUser.class);
      return xcUser;
      }
      } catch (Exception e) {
      log.error("获取当前登录用户身份信息出错:{}", e.getMessage());
      e.printStackTrace();
      }
      return null;
      }


      // 这里使用内部类,是为了不让content工程去依赖auth工程
      @Data
      public static class XcUser implements Serializable {

      private static final long serialVersionUID = 1L;

      private String id;

      private String username;

      private String password;

      private String salt;

      private String name;
      private String nickname;
      private String wxUnionid;
      private String companyId;
      /**
      * 头像
      */
      private String userpic;

      private String utype;

      private LocalDateTime birthday;

      private String sex;

      private String email;

      private String cellphone;

      private String qq;

      /**
      * 用户状态
      */
      private String status;

      private LocalDateTime createTime;

      private LocalDateTime updateTime;
      }
      }
  • 下面在内容管理服务中测试此工具类,以查询课程信息接口为例

    1
    2
    3
    4
    5
    6
    7
    8
    9
        @ApiOperation("根据课程id查询课程基础信息")
    @GetMapping("/course/{courseId}")
    public CourseBaseInfoDto getCourseBaseById(@PathVariable Long courseId) {
    + SecurityUtil.XcUser user = SecurityUtil.getUser();
    - Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    - System.out.println("当前用户身份为:" + principal);
    + System.out.println("当前用户身份为:" + user);
    return courseBaseInfoService.getCourseBaseInfo(courseId);
    }
  • 下面进行测试
    1. 重启认证服务、内容管理服务
    2. 生成新的令牌
    3. 携带令牌访问内容管理服务的查询课程接口,控制台可以看到输入的用户信息,打断点也行(不过我懒得截图)
      1
      当前用户身份为:SecurityUtil.XcUser(id=52, username=Kyle, password=null, salt=null, name=Kiki, nickname=null, wxUnionid=null, companyId=1232141425, userpic=null, utype=101002, birthday=null, sex=1, email=null, cellphone=null, qq=null, status=, createTime=2022-09-28T08:32:03, updateTime=null)

如何支持认证方式多样化

统一认证入口

  • 目前各大网站的认证方式非常丰富:账号密码认证、手机验证码认证、扫码登录等
  • 基于当前研究的Spring Security认证流程如何支持多样化的认证方案呢?
    1. 支持账号和密码认证
      • 采用OAuth2协议的密码模式即可实现
    2. 支持手机号加验证码认证
      • 用户认证提交的是手机号和验证码,并不是账号和密码
    3. 微信扫码认证
      • 基于OAuth2协议与微信交互,学成在线网站会向微信服务器申请一个令牌,然后携带令牌去微信查询用户信息,查询成功则用户在学成在线项目认证通过
  • 目前我们测试通过OAuth2的密码模式,用户认证会提交账号和密码,由DaoAuthenticationProvider调用UserDetailsService的loadUserByUsername()方法获取UserDetails用户信息
  • 在前面我们自定义了UserDetailsService接口实现类,通过loadUserByUsername()方法根据账号查询用户信息
  • 而不同的认证提交方式的数据不一样,例如
    • 手机加验证码方式:会提交手机号和验证码
    • 账号密码方式:会提交账号、密码、验证码
  • 我们可以在loadUserByUsername()方法上做文章,将用户原来提交的账号数据改为提交一个JSON数据,JSON数据可以扩展不同的认证方式所提交的各种参数
  • 首先创建一个DTO类用于接收各种认证参数
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    @Data
    public class AuthParamsDto {
    private String username; //用户名

    private String password; //域 用于扩展

    private String cellphone;//手机号

    private String checkcode;//验证码

    private String checkcodekey;//验证码key

    private String authType; // 认证的类型 password:用户名密码模式类型 sms:短信模式类型

    private Map<String, Object> payload = new HashMap<>();//附加数据,作为扩展,不同认证类型可拥有不同的附加数据。如认证类型为短信时包含smsKey : sms:3d21042d054548b08477142bbca95cfa; 所有情况下都包含clientId
    }
  • 同时我们也需要修改loadUserByUsername()方法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    @Service
    public class UserDetailsImpl implements UserDetailsService {
    @Autowired
    XcUserMapper xcUserMapper;

    /**
    * @param s 用户输入的登录账号
    * @return UserDetails
    * @throws UsernameNotFoundException
    */
    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
    + AuthParamsDto authParamsDto = null;
    + try {
    + authParamsDto = JSON.parseObject(s, AuthParamsDto.class);
    + } catch (Exception e) {
    + log.error("认证请求数据格式不对:{}", s);
    + throw new RuntimeException("认证请求数据格式不对");
    + }
    - // 没别的意思,只是变量名看着舒服
    - String name = s;
    + String name = authParamsDto.getUsername();
    // 根据username去XcUser表中查询对应的用户信息
    XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, name));
    // 返回空表示用户不存在,SpringSecurity会帮我们处理
    if (user == null) {
    return null;
    }
    // 取出数据库存储的密码
    String password = user.getPassword();
    user.setPassword(null);
    String userString = JSON.toJSONString(user);
    // 创建UserDetails对象,并返回,注意这里的authorities必须指定
    return User.withUsername(userString).password(password).authorities("test").build();
    }
    }
  • 刚刚我们重写的loadUserByUsername()方法是由DaoAuthenticationProvider调用的,而DaoAuthenticationProvider中有一个方法是用于校验密码的,但是并不是所有的校验方式都需要密码,所以我们现在需要重写一个DaoAuthenticationProviderCustom
    • DaoAuthenticationProvider中会校验密码
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
      if (authentication.getCredentials() == null) {
      this.logger.debug("Authentication failed: no credentials provided");
      throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
      } else {
      String presentedPassword = authentication.getCredentials().toString();
      if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
      this.logger.debug("Authentication failed: password does not match stored value");
      throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
      }
      }
      }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    @Component
    public class DaoAuthenticationProviderCustom extends DaoAuthenticationProvider {
    // 由于DaoAuthenticationProvider调用UserDetailsService,所以这里需要注入一个
    @Autowired
    public void setUserDetailsService(UserDetailsService userDetailsService){
    super.setUserDetailsService(userDetailsService);
    }

    // 屏蔽密码对比,因为不是所有的认证方式都需要校验密码
    @Override
    protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
    // 里面啥也不写就不会校验密码了
    }
    }
  • 同时也需要修改WebSecurityConfig类,指定DaoAuthenticationProviderCustom
    1
    2
    3
    4
    5
    6
    7
    @Autowired
    DaoAuthenticationProviderCustom daoAuthenticationProviderCustom;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) {
    auth.authenticationProvider(daoAuthenticationProviderCustom);
    }
  • 重启认证服务,测试申请令牌,传入账号信息改为JSON数据,打个断点,看看传入的请求参数是否为JSON格式
    1
    2
    #### 密码模式
    POST localhost:53070/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username={"username":"Kyle","password":"111111"}
  • 经过测试,我们发现loadUserByUsername()方法可以正常接收到认证请求中的JSON数据,并且可以正确查询到用户信息
  • 有了这些认证参数,我们可以定义一个Service接口去进行各种方式的认证,然后该Service的各种实现类来实现各种方式的认证
  • 定义用户信息,为了可扩展性,我们让其继承XcUser
    • 这里最好不要直接用XcUser类,理由在之前的文章也说过,万一我们需要扩展一些其他的用户信息,那么我们直接修改XcUser类是不现实的,因为XcUser类对应的是数据库中的表。所以即使我们要使用XcUser类作为返回类型,也最好是让一个其他的类继承XcUser
    1
    2
    3
    4
    @Data
    public class XcUserExt extends XcUser {

    }
  • 定义认证Service接口
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    /**
    * 认证Service
    */
    public interface AuthService {
    /**
    * 认证方法
    * @param authParamsDto 认证参数
    * @return 用户信息
    */
    XcUserExt execute(AuthParamsDto authParamsDto);
    }
  • 定义AuthService接口的实现类,即各种认证方式
    • 一个接口的多种实现,我们依靠beanName来做区分,例如这里的password_authservice,见名知意就知道是密码登录方式
      1
      2
      3
      4
      5
      6
      7
      8
      @Service("password_authservice")
      public class PasswordAuthServiceImpl implements AuthService {

      @Override
      public XcUserExt execute(AuthParamsDto authParamsDto) {
      return null;
      }
      }
    • 这里的wx_authservice,一看就是微信扫码方式
      别问我为啥微信不用WeChat,我怕前端传过来的就是wx,而我又不想去动前端代码
      1
      2
      3
      4
      5
      6
      7
      8
      @Service("wx_authservice")
      public class WxAuthServiceImpl implements AuthService {

      @Override
      public XcUserExt execute(AuthParamsDto authParamsDto) {
      return null;
      }
      }
  • 修改loadUserByUsername()
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
        @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
    AuthParamsDto authParamsDto = null;
    try {
    authParamsDto = JSON.parseObject(s, AuthParamsDto.class);
    } catch (Exception e) {
    log.error("认证请求数据格式不对:{}", s);
    throw new RuntimeException("认证请求数据格式不对");
    }
    + // 获取认证类型,beanName就是 认证类型 + 后缀,例如 password + _authservice = password_authservice
    + String authType = authParamsDto.getAuthType();
    + // 根据认证类型,从Spring容器中取出对应的bean
    + AuthService authService = applicationContext.getBean(authType + "_authservice", AuthService.class);
    + XcUserExt user = authService.execute(authParamsDto);
    - String name = authParamsDto.getUsername();
    - // 根据username去XcUser表中查询对应的用户信息
    - XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, name));
    // 返回空表示用户不存在,SpringSecurity会帮我们处理
    if (user == null) {
    return null;
    }
    // 取出数据库存储的密码
    String password = user.getPassword();
    user.setPassword(null);
    String userString = JSON.toJSONString(user);
    // 创建UserDetails对象,并返回,注意这里的authorities必须指定
    return User.withUsername(userString).password(password).authorities("test").build();
    }

实现账号密码认证

  • 上面我们只是简单定义了账号密码认证的实现类,并没有编写具体逻辑,那这个小节我们就来具体实现账号密码认证
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    @Service("password_authservice")
    public class PasswordAuthServiceImpl implements AuthService {

    @Autowired
    XcUserMapper xcUserMapper;

    @Autowired
    PasswordEncoder passwordEncoder;

    @Override
    public XcUserExt execute(AuthParamsDto authParamsDto) {
    // 1. 获取账号
    String username = authParamsDto.getUsername();
    // 2. 根据账号去数据库中查询是否存在
    XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, username));
    // 3. 不存在抛异常
    if (xcUser == null) {
    throw new RuntimeException("账号不存在");
    }
    // 4. 校验密码
    // 4.1 获取用户输入的密码
    String passwordForm = authParamsDto.getPassword();
    // 4.2 获取数据库中存储的密码
    String passwordDb = xcUser.getPassword();
    // 4.3 比较密码
    boolean matches = passwordEncoder.matches(passwordForm, passwordDb);
    // 4.4 不匹配,抛异常
    if (!matches) {
    throw new RuntimeException("账号或密码错误");
    }
    // 4.5 匹配,封装返回
    XcUserExt xcUserExt = new XcUserExt();
    BeanUtils.copyProperties(xcUser, xcUserExt);
    return xcUserExt;
    }
    }
  • 修改loadUserByUsername()方法,我们可以将最后的封装UserDetails的相关代码抽取为一个方法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
        @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
    AuthParamsDto authParamsDto = null;
    try {
    authParamsDto = JSON.parseObject(s, AuthParamsDto.class);
    } catch (Exception e) {
    log.error("认证请求数据格式不对:{}", s);
    throw new RuntimeException("认证请求数据格式不对");
    }
    // 获取认证类型,beanName就是 认证类型 + 后缀,例如 password + _authservice = password_authservice
    String authType = authParamsDto.getAuthType();
    // 根据认证类型,从Spring容器中取出对应的bean
    AuthService authService = applicationContext.getBean(authType + "_authservice", AuthService.class);
    XcUserExt user = authService.execute(authParamsDto);
    - // 返回空表示用户不存在,SpringSecurity会帮我们处理
    - if (user == null) {
    - return null;
    - }
    - // 取出数据库存储的密码
    - String password = user.getPassword();
    - user.setPassword(null);
    - String userString = JSON.toJSONString(user);
    - // 创建UserDetails对象,并返回,注意这里的authorities必须指定
    - return User.withUsername(userString).password(password).authorities("test").build();
    + return getUserPrincipal(user);
    }

    + public UserDetails getUserPrincipal(XcUserExt user) {
    + String[] authorities = {"test"};
    + String password = user.getPassword();
    + user.setPassword(null);
    + String userJsonStr = JSON.toJSONString(user);
    + UserDetails userDetails = User.withUsername(userJsonStr).password(password).authorities(authorities).build();
    + return userDetails;
    + }
  • 重启认证服务,测试申请令牌接口
    1. 申请令牌,注意JSON数据中要带上authType
      1
      2
      ###### 密码模式
      POST localhost:53070/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username={"username":"Kyle","password":"111111","authType":"password"}
    2. 测试密码错误的情况
    3. 测试账号不存在的情况
  • 可以成功获取authType,并正确查询到用户信息

验证码服务

创建验证码服务工程

  • 在认证时,一般都需要输入验证码,验证码有什么用?
    • 验证码可以防止恶性攻击,例如
      • XSS跨站脚本攻击
      • CSRF跨站请求伪造攻击
  • 一些比较复杂的图形验证码可以有效防止恶性攻击
  • 为了保护系统的安全,在进行一些比较重要的操作时,都需要验证码,例如
    1. 认证
    2. 找回密码
    3. 人机判断
    4. 支付验证等
  • 验证码的类型也有很多:图片、语音、手机短信验证码等
  • 本项目创建单独的验证码服务微各业务提供验证码的生成、校验等服务
  • 拷贝黑马提供的xuecheng-plus-checkcode验证码服务到自己的工程目录,修改bootstrap.yml,在nacos中新增checkcode-dev.yaml
    1
    2
    3
    4
    server:
    servlet:
    context-path: /checkcode
    port: 53075
  • 新增网关路由配置
    1
    2
    3
    4
    5
    6
    7
    8
    - id: auth-service
    uri: lb://auth-service
    predicates:
    - Path=/auth/**
    - id: checkcode
    uri: lb://checkcode
    predicates:
    - Path=/checkcode/**
  • 由于黑马更新了视频,现在验证码是缓存在redis中的,所以我们需要部署redis
    1
    2
    3
    docker pull redis
    docker run -d --name myredis -p 6379:6379 redis
    docker start myredis
  • 同时在nacos中配置redis-dev.yaml,group设置为xuecheng-plus-common
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    spring:
    redis:
    host: 192.168.101.128
    port: 6379
    password:
    database: 0
    lettuce:
    pool:
    max-active: 20
    max-idle: 10
    min-idle: 0
    timeout: 10000
  • 在验证码模块中引入redis的配置
    1
    2
    3
    +  - data-id: redis-${spring.profiles.active}.yaml
    + group: xuecheng-plus-common
    + refresh: true
  • 在验证码模块中引入redis依赖
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <!--redis依赖-->
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <!--common-pool-->
    <dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
    </dependency>
  • 先草草看一遍黑马提供的验证码服务,有个CheckCodeService是验证码接口,其内部还有一个CheckCodeStore接口,CheckCodeStore接口是负责存储验证码的
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    public interface CheckCodeStore {

    /**
    * @param key key
    * @param value value
    * @param expire 过期时间,单位秒
    * @return void
    * @description 向缓存设置key
    * @author Mr.M
    * @date 2022/9/29 17:15
    */
    void set(String key, String value, Integer expire);

    String get(String key);

    void remove(String key);
    }
  • 顺藤摸瓜,找到它的实现类为MemoryCheckCodeStore,现在我们只需要修改这个类,改为用Redis缓存验证码即可
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    @Component("MemoryCheckCodeStore")
    public class MemoryCheckCodeStore implements CheckCodeService.CheckCodeStore {
    // 注入StringRedisTemplate
    @Autowired
    StringRedisTemplate redisTemplate;


    @Override
    public void set(String key, String value, Integer expire) {
    redisTemplate.opsForValue().set(key, value, expire, TimeUnit.MINUTE);
    }

    @Override
    public String get(String key) {
    return (String) redisTemplate.opsForValue().get(key);
    }

    @Override
    public void remove(String key) {
    redisTemplate.delete(key);
    }
    }

验证码接口测试

  • 黑马提供的验证码服务中的Controller中有一个方法,是用来生成验证码图片的
    1
    2
    3
    4
    5
    @ApiOperation(value="生成验证信息", notes="生成验证信息")
    @PostMapping(value = "/pic")
    public CheckCodeResultDto generatePicCheckCode(CheckCodeParamsDto checkCodeParamsDto){
    return picCheckCodeService.generate(checkCodeParamsDto);
    }
  • 我们使用HttpClient测试该接口
    1
    2
    #### 获取验证码图片
    POST localhost:53075/checkcode/pic
  • 响应结果如下,图片是以base64编码格式存储的,我们可以复制直接在浏览器中打开
    1
    2
    3
    4
    {
    "key": "checkcode:20a2ccb511bc472ea785db14d0a547ba",
    "aliasing": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIoAAAAiCAIAAAAPqtawAAAL9UlEQVR42t2aeVRV1xXGH86CgsNyFgccEJazqDjgPOKEshwAxVlUQMWFOE8FWTiLIi6cWSqi4ISCOKA2U5s0HZI2aZqGpk3TNDGmbdqkSeuw+lvneJ7Pd++77z4ID+33x1v33eEM+zt7f3vfeyz3FH6s8IrAqwKvKbwu8IbATwR+qvCmwFsCP1N4W+DnAr8Q+KXCrxTeEXhX4NcCv1F4T+B9gd8qfCDwO4EPBX6v8JFAqcAfFD4W+KPCnwQ+EfizwqcCfxH4TOCvCp8LfCFwX+FLgQcCXwn8TeHvCv8Q+FrgnwL/UvhG4FuBfyt8J/C9wH8E/qtgsaPnFQVbel5X0NLzpoItPW8rGNDzjoKWnvcUbOn5QEFLz0cKtvR8rGDLjR09nypo6flcwZaeLxW09Nhxo0vPNwq29HynoKXn4cOH+vS8qqB1HV163lLQuo4xPe8qaOl5X0HrOrr0lCoY0POJgtZ1dOn5QkHrOsb0fK1gkp7vFVygxyCy6dJjENms9BhENvP0fKjwA9LzmYJJer5ScBrZ7Oj5VsFRZNOh5/9VeHiEO19S4bGn5+UVHu7kqqSHrmlH0sNtXH1JhUefHrcJz+nTp7k/JyeH2+iuzMLDL61ZHYimOObMjBkzJk+eTKdlEx5dehgbtzEFrla08BjR46rw6NJjJzy29HAcEBCwaNGinj17Hjp0iAO4uXHjhqvCs3jxYshITU0dOnQo4zxz5syaNWsiIiKWLl169OjRffv2FRQU4JrlFx4eZHYxMTEJCQnDhg1zSk/5hecpPe4XHp5KSUnp2rWrt7d3jRo1OnTowEFUVNSIESPy8vLu3r2LlR0JD4A/iKEvxlxYWJifn4+jZGRkwHS1atXatGnDr6+v7+jRoyEJyhleeYSH27KyslauXNmkSZMqVao0bNgQkjIzMytaeJ6jx53Ck5ubi7ts2bKlefPmXl5e9evXx6AeHh5169YNCgpq27bt3Llzd+zYQQta4eGAk9nZ2aGhodeuXRs0aNDYsWOZBb5CC/jQpEmTaNDHx6dv376xsbGMyqnwQDYSiG4xSMkNJFm9B+Pgo56enrRvsVhCQkLghpbNC48uPQbC45Ae9wjPpUuXsGxJScm2bduSkpIgIzg4uEWLFtWrV69atSomGDBgQLt27WbPns1ItMJz7NixefPm0VFcXJy/vz+/y5cv79+/P/63detWfAhTduzYMTIyEpeiO0f0yIyuuLj4RwIHDx5kTZw9e/bWrVu4r+QGnrghPj6+igBjW7hwIa7PyE0KD5eYNVM+efIkrmxeeFyj5wcUHmvORms8e/v27RMnTqSnp2Prli1b4knNmjUjRiUnJxO4GICt8DBUFIW04siRI/hHYmLiDIG9e/dCEvEHgrEj9CBIDMM4L+CejRs30g6MoiiBgYG7d+++cuUKc5H0HD58+Pz58xAvm4Ue/B55KyoqMik8WHjixIk1a9bkWVaAeeF59OiR5QWpeLA10+jVq1d0dPS0adMITUQtvITz0oGs9OB28tlVq1Zx8/Tp03fu3Im9Vq9ejUvhOkS28PBw0g16J7c2rngGDhyI9fnFdp06dWrQoMH8+fPpAmK4H+Eh9yNsjhkzhhVTu3ZtbmNgOCUtO6WHTA+ThoWF1alTxyIAqeaF5xk9lV7xHD9+HMfHdeAGtW/UqNHNmzeXLVt2584drupWPMRG7IjtyNwIhrjLhg0bEHAOiDz0ZeZVG0EMmpFAPANpQfnwWqawa9cuuoMeYixtEjlxUMjjNn6xiV3apis8DB524UaKFmAu5oXHnp7KetXGNCADbgggRDb6YkrMBJ0nSTOoeOCGAzIFMjQIRrpIE1asWEEyjc4b0wM3w4cP5y9PkU2QQGJEXJDEhEscSO8ZPHgwiWW/fv0Qp9atW0tD41vGwgMYLaQS0/A5Kz2cNy88LtBTEcIjueFxVv2BAweYAzPBEOgQ6kJiTbgzftVGd9xGWnXhwgWcj5R31KhRDI9LTl+10SAhi2GQLpIR+Pn51apVi8e7dOnCCCEbDiAeepo2bUqWOHPmzCFDhmBiPNug4uEXdkkuCNRQTuMEN8lN48aNXRIefXrcLDyEspEjR5JkEzewEVKMfnKP01dtuBfDg5UePXoQzXA1eCotLaVfk994aJOsBFZIDXA7/IbAxe+mTZu4yp0I2KxZsyZMmDB+/Hhu4AArw5+jioczdMoUIJ7pQOSSJUvOnTsn6aHOc0l4ntJTucJDutm5c2fWJkkXEk2zly9fNvmqDYZOnTrF2kc8MDFyTePG9NgKD0Mi34MPGMJrSfzwD9Y7sY6rEI8QEipJ7fAAitxx48ZhZRI8O3okN6Tg8k1Vt27doJk6gSyDaZIHSnooul0SnufocVV4dOkpg/CQyLLKIAYlIA1z6VUbRJKt7d+/H+Nu376dYEgvZj7ByWqUzBALUnVhuz59+uArHLPqoYe5kA0SoEhPyB5jYmJIjmWCN3XqVF3XwVzr168nryOmkQQmJCRgIkpR4qSkh9m5JDxm6akg4eGX7lB10oH27dtThKalpZl81UbKQIObN29GruvVqzdlyhQGJt9Sc0k6kNM3oYRE2KWShRJauH79+pw5c8gUOEkVSYDCAxgVi4BEDuOSI2DlBQsW6NKTmZnJLPBjghjZIAsIiYKePXv2SHqIvS4Jjw49bhYeGaZbtWpFVcHSo0eT33hoh5FjU8IRYYf4JumhFxqHITPfeNAYnCMpKYmMkQMmhdIwEtI2ailKKPIuCKPNq1evEn69vLwIg9xPg1p6aM1DAFLXrVv34MED+S6HNSTpoVmX6Hn8+LGlcoWHcE9cwijdu3cnfzP5jYe/tED1zlKFXWKULF0pYhAMykwEOTc31+mrNsombI3mYztiEbNjPESwyMhIqhx/f39oY9HQCCJHfOO2wMBAzE13dsLDAeWRx/MIDg6Oi4sj8ZH0EBXxV5JMk8LjhJ4KFR6apTWIoaIkdGBlYj3N6uZsdvQgEiTi0ENkIyOA19TUVByRWicnJwcx5yp6DlsGwsMYOE9oRflwC+zIlCGVdIAMmHyBNB0HolnOcz/dYWJ8aO3atbSsTanxZkTLQw8WG+CXJoXnGT3uFx4a4REWqa+vL1k1K4vgwOOcNBAe+SwLmfju4+ODXCO8yAPtENkxhKenJ7GOXBbKs7OzCXGO6KFTrEzsImMkYyYpuHjxIndygJhRFyNCiYmJ+GJRURFhkyQC4zJIPCwrK8tRQYre4M3wRBjUcgPS09NNCo89Pe4UHvmLdShFKf0IMlSj8mupgfDQNQscn+vduzfJa0REBLGRiIERZW4dHh5OUCJ9IrCQ0Unv0RUeUmqopSlMWVBQAMEQhv8xJMZDRKJ9skryAuihEc4T63BxvJPuzHyCKykpIZ8ki5PEBAUFEQCZtUnh0afHbcJDaU3RQ3hh+XPgVHjoi5bR8ICAAG9v7+jo6KioKJhgRRMhsa/8DhQfH49ZMX1eXh4Bh8cdbS6APOiBDIpTDnAL1gfhC++EKiIn7citCvfv34cVBJKSubi4mMGY/MbDX5m4g4yMDPMVjxN63FDx0AL3yx51937YCU9hYSF2ZNWHhIT4C1CTwhCZElSRDlBwYGvKQMIUZzAlXfOgo80FrAyUH0ekkdjYWJYIN0B2SkoKbBF1qTGtu9oYhvUDtvnNBZyUUdEa1swLz1N6KuVVW9l2tZ0VoJIgv8Ky8gMajTBCIom1IMVp0BVmYby5gKhFWCOo+vn5JScny1wcC6BbZGjwjQXotDy72vhLO5IemnVKj63wPEfPS7GrDWKYLckPwyaOk25RALL8qSgdbTo02FzAydLSUu7BXRAqxsl4iGMMkmnSNZfkzqky0wNYT2FhYaGhofn5+eYrHn16DIRHl55y7mrTpcdRxSP/EoLS0tJwDrmr7d69e3YbQp2+atPu/WCozEVuxXK6q834G085NxfY0vPkyRNLZQmP002H2opHnseIDIxmy7Db3dVdbe7cTq1Pz0skPC/ydmpdegx2tZkRnmf0uFl4dOkxEB5deuy2U7sqPLr0GGynLvOmQ4NvPMbCY0/Piyw8jugpv/C4tJ3ancID/geQCXNUYmEBQwAAAABJRU5ErkJggg=="
    }
  • 同时在Redis图形化界面中也可以看到我们缓存的验证码
  • Controller中还有一个校验验证码的方法
    1
    2
    3
    4
    5
    @PostMapping(value = "/verify")
    public Boolean verify(String key, String code){
    Boolean isSuccess = picCheckCodeService.verify(key,code);
    return isSuccess;
    }
  • 我们同样使用HttpClient进行测试
    1
    2
    ###### 校验验证码
    POST localhost:53075/checkcode/verisfy?key=checkcode:c3dce1413f95414e943dcf0a97983fe8&code=HZCG
    • 当验证码和key正确的时候,输出true
    • 当验证码和key错误的时候,输出false

账号密码认证

需求分析

  • 到目前为止,账号和密码认证所需要的技术、组件已开发完毕,下面实现账号密码认证,执行流程如下

账号密码认证开发

  1. 定义远程调用验证码服务的接口
    1
    2
    3
    4
    5
    @FeignClient(value = "checkcode")
    public interface CheckCodeClient {
    @PostMapping(value = "/checkcode/verify")
    public Boolean verify(@RequestParam("key") String key,@RequestParam("code") String code);
    }
  2. 启动类添加注解
    1
    @EnableFeignClients(basePackages = "com.xuecheng.*.feignclient")
  3. 完善PasswordAuthServiceImpl
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    @Service("password_authservice")
    public class PasswordAuthServiceImpl implements AuthService {

    @Autowired
    XcUserMapper xcUserMapper;

    @Autowired
    PasswordEncoder passwordEncoder;

    + @Autowired
    + CheckCodeClient checkCodeClient;

    @Override
    public XcUserExt execute(AuthParamsDto authParamsDto) {
    + // 校验验证码
    + String checkcode = authParamsDto.getCheckcode();
    + String checkcodekey = authParamsDto.getCheckcodekey();
    + if (StringUtils.isBlank(checkcode) || StringUtils.isBlank(checkcodekey)){
    + throw new RuntimeException("验证码为空");
    + }
    + Boolean verify = checkCodeClient.verify(checkcodekey, checkcode);
    + if (!verify){
    + throw new RuntimeException("验证码输入错误");
    + }
    // 1. 获取账号
    String username = authParamsDto.getUsername();
    // 2. 根据账号去数据库中查询是否存在
    XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, username));
    // 3. 不存在抛异常
    if (xcUser == null) {
    throw new RuntimeException("账号不存在");
    }
    // 4. 校验密码
    // 4.1 获取用户输入的密码
    String passwordForm = authParamsDto.getPassword();
    // 4.2 获取数据库中存储的密码
    String passwordDb = xcUser.getPassword();
    // 4.3 比较密码
    boolean matches = passwordEncoder.matches(passwordForm, passwordDb);
    // 4.4 不匹配,抛异常
    if (!matches) {
    throw new RuntimeException("账号或密码错误");
    }
    // 4.5 匹配,封装返回
    XcUserExt xcUserExt = new XcUserExt();
    BeanUtils.copyProperties(xcUser, xcUserExt);
    return xcUserExt;
    }
    }
  • 重启服务,测试登录,若登录成功,右上角可以看到登录用户信息,同时cookie中也有jwt令牌
  • 我这里遇到的问题:设置cookie一直失败,暂未解决。目前只能在控制台手动设置cookie
    1
    document.cookie = "jwt=令牌内容"
  • 设置cookie的代码是前端js写的
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    login(){
    //转json串
    let usernameJson = JSON.stringify(this.usernamejson)
    console.log(usernameJson)
    this.formdata.username = usernameJson;
    let params = querystringify(this.formdata);
    loginSubmit(params).then(res=>{
    console.log(res) // 控制台输出正常
    console.log(res.access_token) // 控制台输出正常
    if(res&& res.access_token){
    console.log("进入到设置cookie之前") // 控制套输出正常
    setCookie("jwt","123",30); // 无效
    if(this.autologin){
    setCookie('jwt',res.access_token,30)
    }else{
    console.log("进入到setCookie方法上面") // 控制台输出正常
    setCookie('jwt',"123",0) // 无效
    }

    this.$message.success('登录成功')
    if(this.returnUrl){
    top.location=this.returnUrl
    }else{
    top.location='/'
    }

    }

    }).catch(error=>{
    if(error&&error.response&&error.response.data&&error.response.data.error_description){
    this.$message.error(error.response.data.error_description)
    }
    this.getCheckCode();
    })
    }
  • 对应的setCookie方法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    function setCookie(name,value,Days){
    if(Days==0){
    document.cookie = name + "="+ escape (value) + ";domain=localhost;expires=0;path=/" ;
    }else{
    var exp = new Date();
    exp.setTime(exp.getTime() + Days*24*60*60*1000);
    document.cookie = name + "="+ escape (value) + ";domain=localhost;expires=" + exp.toGMTString()+";path=/";
    }
    }
  • 但是logout()调用的setCookie生效,将手动set的jwt令牌删除了
    1
    2
    3
    4
    function logout(){
    setCookie('jwt','',-1)
    window.location='/'
    }
  • 单步调试可以看到jwt令牌应该是被成功设置到了cookie,但cookie里还是没有
  • 有前端大佬可以指点一下吗
  • bug莫名其妙的解决了,现在登录可以成功设置cookie,右上角也可以看到登录用户信息
  • 我现在怀疑我被智子入侵了,物理学不存在了
  • debug花了快一天,还请教了chatGPT两个多小时,麻了

微信登录

接入规范

接入流程

  • 接口文档:https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html
  • 微信OAuth2.0授权登录目前支持authorization_code模式,适用于拥有 server 端的应用授权。该模式整体流程为:
    1. 第三方发起微信授权登录请求,微信用户允许授权第三方应用后,微信会拉起应用或重定向到第三方网站,并且带上授权临时票据 code 参数;
    2. 通过 code 参数加上 AppID 和AppSecret等,通过 API 换取access_token;
    3. 通过access_token进行接口调用,获取用户基本数据资源或帮助用户实现基本操作。
  • 获取access_token时序图
  • 这里采用的是将微信登录二维码内嵌到自己页面,然后用户扫码登录,查看官方文档的使用说明
    1. 在页面引入如下JS
      1
      http://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js
    2. 在需要使用微信登录的地方实例以下JS对象

      这里的AppId和回调地址是尚硅谷提供的,所以我们必须修改认证服务的端口为8160,不然不能用(什么NTR剧情)
      修改nacos中的auth-service-dev.yaml,修改端口、appid、appsecret

      1
      2
      3
      4
      5
      server:
      port: 8160
      weixin:
      appid: wxed9954c01bb89b47
      secret: a7482517235173ddb4083788de60b90e

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      var wxObj = new WxLogin({
      self_redirect:true,
      id:"login_container",
      appid: "wxed9954c01bb89b47",
      scope: "snsapi_login",
      redirect_uri: "http://localhost:8160/auth/wxLogin",
      state: token,
      style: "",
      href: ""
      });
    • 这两步黑马已经帮我们完成了,现在页面上就已经有了二维码
    • 参数说明
参数 是否必须 说明
self_redirect true:手机点击确认登录后可以在 iframe 内跳转到 redirect_uri,false:手机点击确认登录后可以在 top window 跳转到 redirect_uri。默认为 false。
id 第三方页面显示二维码的容器id
appid 应用唯一标识,在微信开放平台提交应用审核通过后获得
scope 应用授权作用域,拥有多个作用域用逗号(,)分隔,网页应用目前仅填写snsapi_login即可
redirect_uri 重定向地址,需要进行UrlEncode
state 用于保持请求和回调的状态,授权请求后原样带回给第三方。该参数可用于防止 csrf 攻击(跨站请求伪造攻击),建议第三方带上该参数,可设置为简单的随机数加 session 进行校验
style 提供”black”、”white”可选,默认为黑色文字描述。详见文档底部FAQ
href 自定义样式链接,第三方可根据实际需求覆盖默认样式。详见文档底部FAQ

接入微信登录

接入分析

  • 根据OAuth2协议授权码流程,结合本项目自身特点,分析接入微信扫码登录的流程
  • 本项目认证服务需要做哪些事?
    1. 需要定义接口接收微信下发的授权码
    2. 收到授权码调用微信接口申请令牌
    3. 申请到令牌后,调用微信获取用户信息
    4. 获取用户信息成功,将其写入本项目的用户信息数据库
    5. 重定向到浏览器自动登录

定义接口

  • 定义WxLoginController类
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @Slf4j
    @Controller
    public class WxLoginController {
    @Autowired
    WxAuthServiceImpl wxAuthService;

    @RequestMapping("/wxLogin")
    public String wxLogin(String code, String state) throws IOException {
    log.debug("微信扫码回调,code:{},state:{}",code,state);
    XcUser xcUser = wxAuthService.wxAuth(code);
    if(xcUser==null){
    return "redirect:http://localhost/error.html";
    }
    String username = xcUser.getUsername();
    return "redirect:http://localhost/sign.html?username="+username+"&authType=wx";
    }
    }
  • 定义微信认证的service
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    @Service("wx_authservice")
    public class WxAuthServiceImpl implements AuthService {
    @Autowired
    XcUserMapper xcUserMapper;

    public XcUser wxAuth(String code) {
    //TODO: 获取access_token

    //TODO: 获取用户信息

    // 这里先用个假数据
    XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, "Kyle"));
    //TODO: 添加用户信息到数据库

    return xcUser;
    }

    /**
    * 微信扫码认证,不需要校验密码和验证码
    *
    * @param authParamsDto 认证参数
    * @return
    */
    @Override
    public XcUserExt execute(AuthParamsDto authParamsDto) {
    // 账号
    String username = authParamsDto.getUsername();
    XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, username));
    if (user == null) {
    throw new RuntimeException("账号不存在");
    }
    XcUserExt xcUserExt = new XcUserExt();
    BeanUtils.copyProperties(user, xcUserExt);
    return xcUserExt;
    }
    }
  • 我们现在重启服务,打个断点,扫描二维码,可以进入到我们的微信登录代码,并且拿到了我们的假数据,放行之后,登陆成功,右上角显示登录用户名

申请令牌

  • 接下来请求微信申请令牌
    1. 使用RestTemplate请求微信,在AuthApplication里配置RestTemplate的bean
      1
      2
      3
      4
      5
      @Bean
      RestTemplate restTemplate(){
      RestTemplate restTemplate = new RestTemplate(new OkHttp3ClientHttpRequestFactory());
      return restTemplate;
      }
    2. 定义一个WxAuthService,将刚刚写的wxAuth()方法提取到该接口中
      1
      2
      3
      public interface WxAuthService {
      XcUser wxAuth(String code);
      }
    3. 让WxAuthService实现WxAuthService,这样代码更规范,没别的意思
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      @Service("wx_authservice")
      public class WxAuthServiceImpl implements AuthService, WxAuthService {
      @Autowired
      XcUserMapper xcUserMapper;


      @Override
      public XcUser wxAuth(String code) {
      //TODO: 获取access_token

      //TODO: 获取用户信息

      // 这里先用个假数据
      XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, "Kyle"));
      //TODO: 添加用户信息到数据库

      return xcUser;
      }

      /**
      * 微信扫码认证,不需要校验密码和验证码
      *
      * @param authParamsDto 认证参数
      * @return
      */
      @Override
      public XcUserExt execute(AuthParamsDto authParamsDto) {
      // 账号
      String username = authParamsDto.getUsername();
      XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, username));
      if (user == null) {
      throw new RuntimeException("账号不存在");
      }
      XcUserExt xcUserExt = new XcUserExt();
      BeanUtils.copyProperties(user, xcUserExt);
      return xcUserExt;
      }
      }
    4. 在WxAuthServiceImpl类中定义申请令牌的私有方法,刚刚我们是用的一个死数据登录的,现在我们要从微信获取真实的用户信息
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      private Map<String, String> getAccess_token(String code) {
      // 1. 请求路径模板,参数用%s占位符
      String url_template = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code";
      // 2. 填充占位符:appid,secret,code
      String url = String.format(url_template, appid, secret, code);
      // 3. 远程调用URL,POST方式(详情参阅官方文档)
      ResponseEntity<String> exchange = restTemplate.exchange(url, HttpMethod.POST, null, String.class);
      // 4. 获取相应结果,响应结果为json格式
      String result = exchange.getBody();
      // 5. 转为map
      Map<String, String> map = JSON.parseObject(result, Map.class);
      return map;
      }
    5. 调用令牌方法,打个断点看一下,看看是否能拿到access_token
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      @Override
      public XcUser wxAuth(String code) {
      //TODO: 获取access_token
      Map<String, String> access_token_map = getAccess_token(code);
      //TODO: 获取用户信息


      // 这里先用个假数据
      XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, "Kyle"));
      //TODO: 添加用户信息到数据库

      return xcUser;
      }

获取用户信息

  • 上面我们已经成功获取到了access_token,下面我们拿着access_token来查询用户信息
  • 在WxAuthServiceImpl中定义获取用户信息的方法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    private Map<String, String> getAccess_token(String code) {
    // 1. 请求路径模板,参数用%s占位符
    String url_template = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code";
    // 2. 填充占位符:appid,secret,code
    String url = String.format(url_template, appid, secret, code);
    // 3. 远程调用URL,POST方式(详情参阅官方文档)
    ResponseEntity<String> exchange = restTemplate.exchange(url, HttpMethod.POST, null, String.class);
    // 4. 获取响应结果,响应结果为json格式
    String result = exchange.getBody();
    // 4.1 需要转码
    result = new String(result.getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8);
    // 5. 转为map
    Map<String, String> map = JSON.parseObject(result, Map.class);
    return map;
    }
  • 调用方法,打个断点看一下是否能拿到数据
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    @Override
    public XcUser wxAuth(String code) {
    //TODO: 获取access_token
    Map<String, String> access_token_map = getAccess_token(code);
    String accessToken = access_token_map.get("access_token");

    //TODO: 获取用户信息
    String openid = access_token_map.get("openid");
    Map<String, String> userInfo = getUserInfo(accessToken, openid);

    // 这里先用个假数据
    XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, "Kyle"));
    //TODO: 添加用户信息到数据库

    return xcUser;
    }
  • 这里的headimgurl是你微信的头像图片,如果一切正常,是可以在浏览器中打开查看的

保存用户信息

  • 向数据库保存用户信息,如果用户不存在,则将其保存在数据库
  • 在WxAuthServiceImpl中定义方法addWxUser()
    @Transactional
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36

    public XcUser addWxUser(Map<String, String> user_info_map){
    // 1. 获取用户唯一标识:unionid作为用户的唯一表示
    String unionid = user_info_map.get("unionid");
    // 2. 根据唯一标识,判断数据库是否存在该用户
    XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getWxUnionid, unionid));
    // 2.1 存在,则直接返回
    if (xcUser != null){
    return xcUser;
    }
    // 2.2 不存在,新增
    xcUser = new XcUser();
    // 2.3 设置主键
    String uuid = UUID.randomUUID().toString();
    xcUser.setId(uuid);
    // 2.4 设置其他数据库非空约束的属性
    xcUser.setUsername(unionid);
    xcUser.setPassword(unionid);
    xcUser.setWxUnionid(unionid);
    xcUser.setNickname(user_info_map.get("nickname"));
    xcUser.setUserpic(user_info_map.get("headimgurl"));
    xcUser.setName(user_info_map.get("nickname"));
    xcUser.setUtype("101001"); // 学生类型
    xcUser.setStatus("1");
    xcUser.setCreateTime(LocalDateTime.now());
    // 2.5 添加到数据库
    xcUserMapper.insert(xcUser);
    // 3. 添加用户信息到用户角色表
    XcUserRole xcUserRole = new XcUserRole();
    xcUserRole.setId(uuid);
    xcUserRole.setUserId(uuid);
    xcUserRole.setRoleId("17");
    xcUserRole.setCreateTime(LocalDateTime.now());
    xcUserRoleMapper.insert(xcUserRole);
    return xcUser;
    }
  • 调用保存用户信息
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // 注入自身
    @Autowired
    WxAuthServiceImpl wxAuthService;

    @Override
    public XcUser wxAuth(String code) {
    //TODO: 获取access_token
    Map<String, String> access_token_map = getAccess_token(code);
    String accessToken = access_token_map.get("access_token");

    //TODO: 获取用户信息
    String openid = access_token_map.get("openid");
    Map<String, String> user_info_map = getUserInfo(accessToken, openid);

    //TODO: 添加用户信息到数据库,这里注意使用代理对象调用
    XcUser xcUser = wxAuthService.addWxUser(user_info_map);
    return xcUser;
    }
    • 注意:非事务方法调用事务方法,要使用代理对象调用,前面也提到过这点
    • 这里的addWxUser()方法涉及到了多表操作,所以需要进行事务控制,而wxAuth()是非事务方法,所以这里我们需要注入自身,然后调用addWxUser()
  • Controller层接口代码如下
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @RequestMapping("/wxLogin")
    public String wxLogin(String code, String state) throws IOException {
    log.debug("微信扫码回调,code:{},state:{}",code,state);
    XcUser user = wxAuthService.wxAuth(code);
    if(user==null){
    return "redirect:http://localhost/error.html";
    }
    String username = user.getUsername();
    return "redirect:http://localhost/sign.html?username="+username+"&authType=wx";
    }
  • 重启服务,扫码登录测试,登录成功(我这里特意改了下微信昵称,因为原本昵称是空白的)

用户授权

RBAC

  • 如何实现授权?业界通常基于RBAC实现授权
  • RBAC分为两种方式
    1. 基于角色的访问控制(Role-Based Access Control)
      • 按角色进行授权,例如:只有主体角色为总经理,才可以查询企业运营报表,查询员工工资等,其授权代码可以表示如下
        1
        2
        3
        4
        if(主体.hasRole("总经理角色ID")){
        //TODO: 查询报表
        //TODO: 查询工资
        }
      • 但是如果现在的需求是:总经理和部门经理都可以查询报表和工资,那么此时就需要修改逻辑判断
        1
        2
        3
        4
        if(主体.hasRole("总经理角色ID") || 主体.hasRole("部门经理角色ID")){
        //TODO: 查询报表
        //TODO: 查询工资
        }
      • 此种方式当需要修改角色权限时,就需要修改授权相关的代码,系统可扩展性差
    2. 基于资源的访问控制(Resource-Based Access Control)
      • 按资源(或权限)进行授权,例如:用户必须具有查询工资的权限才可以查询员工工资,其授权代码可以表示如下
        1
        2
        3
        if(主体.hasPermission("查询工资权限标识")){
        //TODO: 查询工资
        }
      • 优点:系统设计时定义好查询工资的权限标识,即使查询工资所需要的角色变化为总经理和部门经理,也不需要修改授权代码,系统可扩展性强

资源服务授权流程

  • 本项目在资源服务内部进行授权,基于资源的授权方式,因为接口在资源服务,通过在接口处添加授权注解实现授权

    1. 配置nginx代理
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      #前端开发服务
      upstream uidevserver{
      server 127.0.0.1:8601 weight=10;
      }
      server {
      listen 80;
      server_name teacher.localhost;
      ssi on;
      ssi_silent_errors on;
      location / {
      proxy_pass http://uidevserver;
      proxy_cookie_path / "/; HTTPOnly; SameSite=strict";
      proxy_cookie_domain uidevserver teacher.localhost;
      }

      location /api/ {
      proxy_pass http://gatewayserver/;

      }
      }
      • 这里配完了之后要是访问教学机构的时候,一直报401,也可以不配,不影响后续操作
      • 原因是nginx没传递cookie,可我明明配了,就是不好使,但是直接访问localhost:8601是有cookie的
      • 不配的解决方案:修改前端代码中的header.html,将教学机构的url从http://teacher.localhost换成http://localhost:8601
    2. 在资源服务集成Spring Security
      • 在需要授权的接口处使用@PreAuthorize("hasAuthority('权限标识符')")进行控制
      • 下面代码指定/course/list接口需要拥有xc_teachmanager_course_list权限
        1
        2
        3
        4
        5
        6
        7
        @ApiOperation("课程查询接口")
        @PreAuthorize("hasAuthority('xc_teachmanager_course_list')")
        @PostMapping("/course/list")
        public PageResult<CourseBase> list(PageParams pageParams, @RequestBody QueryCourseParamDto queryCourseParams) {
        PageResult<CourseBase> result = courseBaseInfoService.queryCourseBaseList(pageParams, queryCourseParams);
        return result;
        }
      • 如果当前用户没有请求该接口的权限,则会抛异常
        1
        org.springframework.security.access.AccessDeniedException: 不允许访问
      • 由于该异常是Spring Security框架抛出的,而我们的统一异常处理器是在base工程中,我们不想让base工程依赖Spring Security,所以采取另一种解决方案
    3. 在统一异常处理器中解析异常信息
      • 我们只判断拿到的异常信息是否为不允许访问,如果是,则提示没有操作此功能的权限
        1
        2
        3
        4
        5
        6
        7
        8
        @ExceptionHandler(Exception.class)
        @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
        public RestErrorResponse exception(Exception exception) {
        log.error("系统异常:{}", exception.getMessage());
        if ("不允许访问".equals(exception.getMessage()))
        return new RestErrorResponse("您没有权限操作此功能");
        return new RestErrorResponse(exception.getMessage());
        }
    4. 重启服务,进行测试
      • 微信扫码登录,登录成功后点击教学机构,由于没有权限,页面会显示您没有权限操作此功能
  • 那么下一步我们就需要给登录用户添加xc_teachmanager_course_list的权限

授权相关的数据模型

  • 如何给用户分配权限呢?查看数据库中的表结构
    • xc_user:用户表,存储了系统用户信息
    • xc_user_role:用户角色表,一个用户可拥有多个角色,一个角色可被多个用户拥有
    • xc_role:角色表,存储了系统的角色类型,角色类型包括:学生、老师、管理员、教学管理员、超级管理员
    • xc_permission:角色权限表,一个角色可拥有多个权限,一个权限可被多个角色拥有
    • xc_menu:权限菜单表,里面记录了各种操作的权限code
  • 本项目要求掌握基于权限模型数据,要求在数据库中操作完成给用户分配权限、查询用户权限等需求

查询用户权限

  • 使用Spring Security进行授权,首先在生成jwt前会查询用户的权限
  • 接下来修改UserDetailsImpl和PasswordAuthServiceImpl,从数据库查询用户的权限,查询权限的SQL代码如下
    1
    2
    3
    4
    5
    SELECT * FROM xc_menu WHERE id IN (
    SELECT menu_id FROM xc_permission WHERE role_id IN (
    SELECT role_id FROM xc_user_role WHERE user_id = '52'
    )
    )
    1. 定义mapper接口
      1
      2
      3
      4
      public interface XcMenuMapper extends BaseMapper<XcMenu> {
      @Select("SELECT * FROM xc_menu WHERE id IN (SELECT menu_id FROM xc_permission WHERE role_id IN ( SELECT role_id FROM xc_user_role WHERE user_id = #{userId} ))")
      List<XcMenu> selectPermissionByUserId(@Param("userId") String userId);
      }
    2. 修改PasswordAuthServiceImpl
      • 首先确保在XcUserExt中添加用户权限
        1
        2
        3
        4
        5
        @Data
        public class XcUserExt extends XcUser {
        //用户权限
        List<String> permissions = new ArrayList<>();
        }
      • 其次修改UserDetailsImpl类中的getUserPrincipal方法,查询权限信息,并设置
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        public UserDetails getUserPrincipal(XcUserExt user) {
        + // 获取用户id
        + String userId = user.getId();
        + // 根据用户id查询用户权限
        + List<XcMenu> xcMenus = xcMenuMapper.selectPermissionByUserId(userId);
        + ArrayList<String> permissions = new ArrayList<>();
        + // 没权限,给一个默认的
        + if (xcMenus.isEmpty()) {
        + permissions.add("test");
        + } else {
        + // 获取权限,加入到集合里
        + xcMenus.forEach(xcMenu -> {
        + permissions.add(xcMenu.getCode());
        + });
        + }
        + // 设置权限
        + user.setPermissions(permissions);
        - String[] authorities = {"test"};
        + String[] authorities = permissions.toArray(new String[0]);
        String password = user.getPassword();
        user.setPassword(null);
        String userJsonStr = JSON.toJSONString(user);
        UserDetails userDetails = User.withUsername(userJsonStr).password(password).authorities(authorities).build();
        return userDetails;
        }

授权测试

  • 现在我们已经完成了认证时从数据库查询用户权限,下面进行测试
  • 我们已经在课程列表查询接口上指明了所需的权限
    1
    2
    3
    4
    5
    6
    7
    @ApiOperation("课程查询接口")
    @PreAuthorize("hasAuthority('xc_teachmanager_course_list')")
    @PostMapping("/course/list")
    public PageResult<CourseBase> list(PageParams pageParams, @RequestBody QueryCourseParamDto queryCourseParams) {
    PageResult<CourseBase> result = courseBaseInfoService.queryCourseBaseList(pageParams, queryCourseParams);
    return result;
    }
  • 重启服务,登录一个有权限的账号,访问课程列表,观察是否可以正确查询,登录一个没权限的账号,观察是否不能查询

细粒度授权

什么是细粒度授权

  • 什么叫细粒度授权?
  • 细粒度授权也叫数据范围授权,即不同的用户所拥有的的操作权限相同,但是能够操作的数据范围是不一样的。
  • 例如:用户A和网易的,用户B是字节的,他们都拥有我的课程的权限,但他们查询到的数据是不一样的,因为不能查询别的机构的课程
  • 本项目有哪些细粒度授权?
  • 我的课程:教学机构只允许查询本机构下的课程信息
  • 我的选课:学生只允许查询自己所选的课
  • 如何实现细粒度授权?
  • 细粒度授权涉及到不同的业务逻辑,通常在service层实现,根据不同的用户进行校验,根据不同的参数查询不同的数据,或操作不同的数据

教学机构细粒度授权

  • 教学机构在维护课程时,只允许维护本机构的课程,教学机构细粒度授权过程如下
    1. 获取当前登录的用户身份
    2. 得到用户所属教育机构的id
    3. 查询该教学机构下的课程信息
  • 最终实现了用户只允许查询自己机构的课程信息
  • 在之前的做法,我们是模拟了一个假数据,用的是一个写死的companyId
  • 根据companyId查询课程,流程如下
    1. 教学机构用户登录系统,从用户身份中取出所属机构的id
    2. 接口层取出当前登录用户的身份,取出机构id
    3. 将机构id传入service方法
    4. service方法将机构id传入dao方法,作为SQL查询参数(where companyId = ${companyId}),最终查询出本机构的课程信息
  • 修改controller层代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @ApiOperation("课程查询接口")
    @PreAuthorize("hasAuthority('xc_teachmanager_course_list')")
    @PostMapping("/course/list")
    public PageResult<CourseBase> list(PageParams pageParams, @RequestBody QueryCourseParamDto queryCourseParams) {
    SecurityUtil.XcUser user = SecurityUtil.getUser();
    Long companyId = null;
    if (StringUtils.isNotEmpty(user.getCompanyId())) {
    companyId = Long.parseLong(user.getCompanyId());
    }
    PageResult<CourseBase> result = courseBaseInfoService.queryCourseBaseList(companyId,pageParams, queryCourseParams);
    return result;
    }
  • 修改service层代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    @Override
    @Transactional
    public PageResult<CourseBase> queryCourseBaseList(Long companyId, PageParams pageParams, QueryCourseParamDto queryCourseParams) {
    // 构建条件查询器
    LambdaQueryWrapper<CourseBase> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(CourseBase::getCompanyId, companyId);
    // 构建查询条件:按照课程名称模糊查询
    queryWrapper.like(StringUtils.isNotEmpty(queryCourseParams.getCourseName()), CourseBase::getName, queryCourseParams.getCourseName());
    // 构建查询条件,按照课程审核状态查询
    queryWrapper.eq(StringUtils.isNotEmpty(queryCourseParams.getAuditStatus()), CourseBase::getAuditStatus, queryCourseParams.getAuditStatus());
    // 构建查询条件,按照课程发布状态查询
    queryWrapper.eq(StringUtils.isNotEmpty(queryCourseParams.getPublishStatus()), CourseBase::getStatus, queryCourseParams.getPublishStatus());
    // 分页对象
    Page<CourseBase> page = new Page<>(pageParams.getPageNo(), pageParams.getPageSize());
    // 查询数据内容获得结果
    Page<CourseBase> pageInfo = courseBaseMapper.selectPage(page, queryWrapper);
    // 获取数据列表
    List<CourseBase> items = pageInfo.getRecords();
    // 获取数据总条数
    long counts = pageInfo.getTotal();
    // 构建结果集
    return new PageResult<>(items, counts, pageParams.getPageNo(), pageParams.getPageSize());
    }

细粒度测试

  • 使用一个课程多的教学机构用户登录项目,查询课程,观察控制台日志输出
    1
    2
    3
    DEBUG [http-nio-53040-exec-1][BaseJdbcLogger.java:137] - ==>  Preparing: SELECT COUNT(*) FROM course_base WHERE (company_id = ?)
    DEBUG [http-nio-53040-exec-1][BaseJdbcLogger.java:137] - ==> Parameters: 1232141425(Long)
    DEBUG [http-nio-53040-exec-1][BaseJdbcLogger.java:137] - <== Total: 1
  • 测试成功,现在可以将所有写死的companyId改为从用户信息获取

实战

  • 由于邮件发送比手机验证码要方便的多,所以这里就只做邮件注册、找回密码了

找回密码

  • 需求:忘记密码需要找回,可以通过邮箱找回密码
  • 页面访问地址:localhost/findpassword.html
  • 接口:
    • 邮箱验证码:/api/checkcode/phone?param1=电子邮箱地址
    • 找回密码:/api/auth/findpassword
      • 请求
        1
        2
        3
        4
        5
        6
        7
        8
        {
        "cellphone":'',
        "email":'',
        "checkcodekey":'',
        "checkcode":'',
        "confirmpwd":'',
        "password":''
        }
  • 执行流程
    1. 校验验证码,不一致则抛异常
    2. 判断两次密码是否一致,不一致则抛异常
    3. 根据邮箱查询用户
    4. 如果找到用户,更新其密码

代码开发

  1. 在checkcode模块中导入邮件发送相关依赖(之前瑞吉外卖那篇文章也用过的)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    <!-- https://mvnrepository.com/artifact/javax.activation/activation -->
    <dependency>
    <groupId>javax.activation</groupId>
    <artifactId>activation</artifactId>
    <version>1.1.1</version>
    </dependency>
    <!-- https://mvnrepository.com/artifact/javax.mail/mail -->
    <dependency>
    <groupId>javax.mail</groupId>
    <artifactId>mail</artifactId>
    <version>1.4.7</version>
    </dependency>
    <!-- https://mvnrepository.com/artifact/org.apache.commons/commons-email -->
    <dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-email</artifactId>
    <version>1.4</version>
    </dependency>
  2. 编写邮件工具类
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    package com.xuecheng.checkcode.utils;

    import java.util.Arrays;
    import java.util.Collections;
    import java.util.List;
    import java.util.Properties;

    import javax.mail.Authenticator;
    import javax.mail.MessagingException;
    import javax.mail.PasswordAuthentication;
    import javax.mail.Session;
    import javax.mail.Transport;
    import javax.mail.internet.InternetAddress;
    import javax.mail.internet.MimeMessage;
    import javax.mail.internet.MimeMessage.RecipientType;

    public class MailUtil {
    public static void main(String[] args) throws MessagingException {
    //可以在这里直接测试方法,填自己的邮箱即可
    sendTestMail("1586385296@qq.com", new MailUtil().achieveCode());
    }

    /**
    * 发送邮件
    * @param email 收件邮箱号
    * @param code 验证码
    * @throws MessagingException
    */
    public static void sendTestMail(String email, String code) throws MessagingException {
    // 创建Properties 类用于记录邮箱的一些属性
    Properties props = new Properties();
    // 表示SMTP发送邮件,必须进行身份验证
    props.put("mail.smtp.auth", "true");
    //此处填写SMTP服务器
    props.put("mail.smtp.host", "smtp.qq.com");
    //端口号,QQ邮箱端口587
    props.put("mail.smtp.port", "587");
    // 此处填写,写信人的账号
    props.put("mail.user", "1586385296@qq.com");
    // 此处填写16位STMP口令
    props.put("mail.password", "plXXXXXXXXiihg");
    // 构建授权信息,用于进行SMTP进行身份验证
    Authenticator authenticator = new Authenticator() {
    protected PasswordAuthentication getPasswordAuthentication() {
    // 用户名、密码
    String userName = props.getProperty("mail.user");
    String password = props.getProperty("mail.password");
    return new PasswordAuthentication(userName, password);
    }
    };
    // 使用环境属性和授权信息,创建邮件会话
    Session mailSession = Session.getInstance(props, authenticator);
    // 创建邮件消息
    MimeMessage message = new MimeMessage(mailSession);
    // 设置发件人
    InternetAddress form = new InternetAddress(props.getProperty("mail.user"));
    message.setFrom(form);
    // 设置收件人的邮箱
    InternetAddress to = new InternetAddress(email);
    message.setRecipient(RecipientType.TO, to);
    // 设置邮件标题
    message.setSubject("Kyle's Blog 邮件测试");
    // 设置邮件的内容体
    message.setContent("尊敬的用户:你好!\n注册验证码为:" + code + "(有效期为一分钟,请勿告知他人)", "text/html;charset=UTF-8");
    // 最后当然就是发送邮件啦
    Transport.send(message);
    }

    /**
    * 生成验证码
    * @return
    */
    public static String achieveCode() { //由于数字 1 、 0 和字母 O 、l 有时分不清楚,所以,没有数字 1 、 0
    String[] beforeShuffle = new String[]{"2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F",
    "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "a",
    "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v",
    "w", "x", "y", "z"};
    List<String> list = Arrays.asList(beforeShuffle);//将数组转换为集合
    Collections.shuffle(list); //打乱集合顺序
    StringBuilder sb = new StringBuilder();
    for (String s : list) {
    sb.append(s); //将集合转化为字符串
    }
    return sb.substring(3, 8);
    }
    }
  3. 在Controller层中添加对应的接口
    1
    2
    3
    4
    5
    @ApiOperation(value = "发送邮箱验证码", tags = "发送邮箱验证码")
    @PostMapping("/phone")
    public void sendEMail(@RequestParam("param1") String email) {

    }
    • 定义FindPswDto类,用于接收找回密码的参数信息
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      @Data
      @NoArgsConstructor
      @AllArgsConstructor
      public class FindPswDto {

      String cellphone;

      String email;

      String checkcodekey;

      String checkcode;

      String password;

      String confirmpwd;
      }
    • 找回密码在auth模块下
      1
      2
      3
      4
      5
      @ApiOperation(value = "找回密码", tags = "找回密码")
      @PostMapping("/findpassword")
      public void findPassword(@RequestBody FindPswDto findPswDto) {

      }
    1. service层
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      public interface SendCodeService {

      /**
      * 向目标邮箱发送验证码
      * @param email 目标邮箱
      * @param code 我们发送的验证码
      */
      void sendEMail(String email, String code);

      }
      1
      2
      3
      public interface VerifyService {
      void findPassword(FindPswDto findPswDto);
      }
  1. 接口实现
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    @Service
    @Slf4j
    public class SendCodeServiceImpl implements SendCodeService {
    public final Long CODE_TTL = 120L;
    @Autowired
    StringRedisTemplate redisTemplate;

    @Override
    public void sendEMail(String email, String code) {
    // 1. 向用户发送验证码
    try {
    MailUtil.sendTestMail(email, code);
    } catch (MessagingException e) {
    log.debug("邮件发送失败:{}", e.getMessage());
    XueChengPlusException.cast("发送验证码失败,请稍后再试");
    }
    // 2. 将验证码缓存到redis,TTL设置为2分钟
    redisTemplate.opsForValue().set(email, code, CODE_TTL, TimeUnit.SECONDS);
    }
    }
    • 注意要事先在auth工程中导入redis的依赖
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      <!--redis依赖-->
      <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-data-redis</artifactId>
      </dependency>
      <!--common-pool-->
      <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-pool2</artifactId>
      </dependency>
    • 接口实现
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      @Service
      public class VerifyServiceImpl implements VerifyService {

      @Autowired
      StringRedisTemplate redisTemplate;

      @Override
      public void findPassword(FindPswDto findPswDto) {
      String email = findPswDto.getEmail();
      String checkcode = findPswDto.getCheckcode();
      Boolean verify = verify(email, checkcode);
      if (!verify) {
      throw new RuntimeException("验证码输入错误");
      }
      String password = findPswDto.getPassword();
      String confirmpwd = findPswDto.getConfirmpwd();
      if (!password.equals(confirmpwd)) {
      throw new RuntimeException("两次输入的密码不一致");
      }
      LambdaQueryWrapper<XcUser> lambdaQueryWrapper = new LambdaQueryWrapper<>();
      lambdaQueryWrapper.eq(XcUser::getEmail, findPswDto.getEmail());
      XcUser user = userMapper.selectOne(lambdaQueryWrapper);
      if (user == null) {
      throw new RuntimeException("用户不存在");
      }
      user.setPassword(new BCryptPasswordEncoder().encode(password));
      userMapper.updateById(user);
      }
      }
  2. 完善Controller层
    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Autowired
    SendCodeService sendCodeService;

    @ApiOperation(value = "发送邮箱验证码", tags = "发送邮箱验证码")
    @PostMapping("/phone")
    public void sendEMail(@RequestParam("param1") String email) {
    String code = MailUtil.achieveCode();
    sendCodeService.sendEMail(email, code);
    }
    1
    2
    3
    4
    5
    @ApiOperation(value = "找回密码", tags = "找回密码")
    @PostMapping("/findpassword")
    public void findPassword(@RequestBody FindPswDto findPswDto) {
    verifyService.findPassword(findPswDto);
    }
  • 重启服务,准备测试
  • 除了前端不会提示你修改成功,改完了也不会跳转到主页,其他一切都正常

注册

  • 需求:为学生提供注册入口,通过此入口注册的用户为学生用户
  • 页面访问地址:localhost/register.html
  • 接口
    • 邮箱验证码:/api/checkcode/phone?param1=邮箱
    • 注册:/api/auth/register
      • 请求
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        {
        "cellphone":'',
        "username":'',
        "email":'',
        "nickname":'',
        "password":'',
        "confirmpwd":'',
        "checkcodekey":'',
        "checkcode":''
        }
  • 执行流程
    1. 校验验证码,不一致,抛异常
    2. 校验两次密码是否一致,不一致,抛异常
    3. 校验用户是否存在,已存在,抛异常
    4. 向用户表、用户关系角色表添加数据,角色为学生

代码开发

  1. 准备一个Dto类,接收注册请求的参数
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public class RegisterDto {

    private String cellphone;

    private String checkcode;

    private String checkcodekey;

    private String confirmpwd;

    private String email;

    private String nickname;

    private String password;

    private String username;

    }
  2. 在Controller层中添加接口
    1
    2
    3
    4
    5
    @ApiOperation(value = "注册", tags = "注册")
    @PostMapping("/register")
    public void register(@RequestBody RegisterDto registerDto) {

    }
  3. Service层

    1
    void register(RegisterDto registerDto);
  4. 接口实现,多表操作,注意事务控制

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    @Override
    @Transactional
    public void register(RegisterDto registerDto) {
    String uuid = UUID.randomUUID().toString();
    String email = registerDto.getEmail();
    String checkcode = registerDto.getCheckcode();
    Boolean verify = verify(email, checkcode);
    if (!verify) {
    throw new RuntimeException("验证码输入错误");
    }
    String password = registerDto.getPassword();
    String confirmpwd = registerDto.getConfirmpwd();
    if (!password.equals(confirmpwd)) {
    throw new RuntimeException("两次输入的密码不一致");
    }
    LambdaQueryWrapper<XcUser> lambdaQueryWrapper = new LambdaQueryWrapper<>();
    lambdaQueryWrapper.eq(XcUser::getEmail, registerDto.getEmail());
    XcUser user = userMapper.selectOne(lambdaQueryWrapper);
    if (user != null) {
    throw new RuntimeException("用户已存在,一个邮箱只能注册一个账号");
    }
    XcUser xcUser = new XcUser();
    BeanUtils.copyProperties(registerDto, xcUser);
    xcUser.setPassword(new BCryptPasswordEncoder().encode(password));
    xcUser.setId(uuid);
    xcUser.setUtype("101001"); // 学生类型
    xcUser.setStatus("1");
    xcUser.setName(registerDto.getNickname());
    xcUser.setCreateTime(LocalDateTime.now());
    int insert = userMapper.insert(xcUser);
    if (insert <= 0) {
    throw new RuntimeException("新增用户信息失败");
    }
    XcUserRole xcUserRole = new XcUserRole();
    xcUserRole.setId(uuid);
    xcUserRole.setUserId(uuid);
    xcUserRole.setRoleId("17");
    xcUserRole.setCreateTime(LocalDateTime.now());
    int insert1 = xcUserRoleMapper.insert(xcUserRole);
    if (insert1 <= 0) {
    throw new RuntimeException("新增用户角色信息失败");
    }
    }
  5. 完善Controller

    1
    2
    3
    4
    5
    @ApiOperation(value = "注册", tags = "注册")
    @PostMapping("/register")
    public void register(@RequestBody RegisterDto registerDto) {
    verifyService.register(registerDto);
    }
  6. 重启服务,进行测试,观察数据库中是否有对应的注册用户信息

  • 测试的时候,需要将手机号填写为邮箱号,这样就可以发邮件了,可以邀请你的小伙伴来做测试
  • 注意:邮箱必须和手机号保持一致!!否则会出错