本项目基于 SpringScurity 框架+ OAuth2 协议实现的用户单点登录。

认证授权的概念

认证:就是身份认证,系统要知道登陆的用户是谁 :

  • 常见的用户身份认证表现形式有
    • 用户名密码登录
    • 微信扫码登录等

授权:不同的系统资源访问以及操作需要角色权限。

认证先发生,才能有授权发生。

业务流程

统一认证

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

img

单点登录

特点:

1、在一个统一登录(认证)界面登录

2、登陆一次就可以访问所有相互信任的应用系统。

第三方认证

扫码登录

img

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

认证授权入门

现在从零开始搭建一个认证服务

创建数据库

创建xc_users数据库

导入课程资料中的xcplus_users.sql脚本。

image-20230615095714675

在nacos中新增auth-service-dev.yaml

配置数据库

server:
  servlet:
    context-path: /auth
  port: 63070
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://192.168.101.65:3306/xc_users?serverTimezone=UTC&userUnicode=true&useSSL=false&
    username: root
    password: root

创建一个空的SpringBoot工程

从课程资料中拷贝xuecheng-plus-auth工程到自己的工程目录下。

此工程是一个普通的spring boot工程,可以连接数据库。

里面基本上什么都没有,只是配置了MP连接数据库

自带一个controller

@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:63070/auth/r/r1

image-20230615100907021

引入依赖

  • 下面向SpringBoot工程集成Spring Security
  • 向pom.xml中加入Spring Security所需的依赖,只要加了依赖,这个系统就被没人配置管控了,只有认证通过的才能访问
<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:63070/auth/r/r1自动进入/login页面,/login页面是由Spring Security提供的

img

那么账号和密码是什么呢?我们需要进行安全配置,创建WebSecurityConfig配置类,继承WebSecurityConfigurerAdapter

他需要配置三个信息

@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
}

1、用户信息

//配置用户信息服务
@Bean
public UserDetailsService userDetailsService() {
    //这里配置用户信息,这里暂时使用这种方式将用户存储在内存中
    // 后面读取数据库
    InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
    manager.createUser(User.withUsername("zhangsan").password("123").authorities("p1").build());
    manager.createUser(User.withUsername("lisi").password("456").authorities("p2").build());
    return manager;
}

2、密码方式(这里用明文方式)

    @Bean
    public PasswordEncoder passwordEncoder() {
//        //密码为明文方式
        return NoOpPasswordEncoder.getInstance();
//        return new BCryptPasswordEncoder();
    }

3、安全拦截机制

这里标识 以/r/**开头的请求都需要 需要认证,其他的不需要认证


//配置安全拦截机制
@Override
protected void configure(HttpSecurity http) throws Exception {
    http
            .authorizeRequests()
            .antMatchers("/r/**").authenticated()//访问/r开始的请求需要认证通过
            .anyRequest().permitAll()//其它请求全部放行
            .and()
            .formLogin().successForwardUrl("/login-success");//登录成功跳转到/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

再次登录,访问资源需要 输入相应的用户名、密码即可访问资源

image-20230615124231310

但对于 非拦截的资源可以直接访问

image-20230615124612220

http.logout().logoutUrl(“/logout”); 退出

本质上我觉得认证授权是一件事

认证可以认为是粗粒度的授权,(对于需要认证的资源来说,需要认证通过的才有权限访问,对于不需要认证的资源,直接放行,赋予其访问权限)

而授权就是对于需要认证的资源,需要更细力度的 权力划分,一般通过用户的角色进行更细粒度划分。

下面演示以下授权:

用用p1权限才能访问r1方法

@RequestMapping("/r/r1")
@PreAuthorize("hasAuthority('p1')")//拥有p1权限方可访问
public String r1() {
    return "访问r1资源";
}

@RequestMapping("/r/r2")
@PreAuthorize("hasAuthority('p2')")//拥有p2权限方可访问
public String r2() {
    return "访问r2资源";
}

重启服务

如果登录的用户访问的是不符合权限的资源,就会发生报错403(标识用户没有资格访问)

这里我用李四我访问r1资源

image-20230615130214749

SpringSecurity工作原理

如果没有这个框架我们会用什么实现呢? 1、 SpringMVC的拦截器 2、SpringWeb中的过滤器Filter

Spring Security所解决的问题就是安全访问控制,而安全访问控制功能其实就是对所有进入系统的请求进行拦截,校验每个请求是否能够访问它所期望的资源。根据前边知识的学习,可以通过Filter或AOP等技术来实现,Spring Security对Web资源的保护是靠Filter实现的,所以从这个Filter来入手,逐步深入Spring Security原理。

image-20230615130619200

整体底层是一种过滤器链来实现的。

FilterChainProxy是一个代理,真正起作用的是FilterChainProxy中SecurityFilterChain所包含的各个Filter,同时这些Filter作为Bean被Spring管理,

它们是Spring Security核心,各有各的职责,但他们并不直接处理用户的认证,也不直接处理用户的授权,而是把它们交给了认证管理器(AuthenticationManager)和决策管理器(AccessDecisionManager)进行处理。

SecurityFilterChain 的过滤器

image-20230615130820413

SecurityFilterChain有多种过滤器

SecurityContextPersistenceFilter 这个Filter是整个拦截过程的入口和出口(也就是第一个和最后一个拦截器),主要用于存储用户的上下文信息(底层基于thraedLocal)

UsernamePasswordAuthenticationFilter(非常非常非常之重要!!!) 用于处理来自表单提交的认证,由他来提取UP来交给人认证管理器AuthenticationManager ,认证通过后会将所有用户信息存储再上下文当中交给第一层过滤器SecurityContextPersistenceFilter

FilterSecurityInterceptor 是用于保护web资源的,使用AccessDecisionManager对当前用户进行授权访问,前面已经详细介绍过了;

ExceptionTranslationFilter 能够捕获来自 FilterChain 所有的异常,并进行处理。但是它只会处理两类异常:AuthenticationException 和 AccessDeniedException,其它的异常它会继续抛出。

Spring Security的执行流程

image-20230615131605549

  1. (1、2)用户提交用户名、密码被SecurityFilterChain中的UsernamePasswordAuthenticationFilter过滤器获取到,封装为请求Authentication,通常情况下是UsernamePasswordAuthenticationToken这个实现类。

  2. (3)然后过滤器将请求Authentication提交至认证管理器(AuthenticationManager)进行认证

    2.1 认证管理器认证管理器 本身没有认证的功能 但是可以选择 认证方式 这里展示了 通过默认方式 去认证也就是委托 认证提供类 DaoAuthentication Provider 的 loadUserByUsername 来 查询/验证 数据库 返回完整的用户数据信息,验证通过后返回

  3. (9)认证成功后,AuthenticationManager身份管理器返回一个被填充满了信息的(包括上面提到的权限信息,身份信息,细节信息,但密码通常会被移除)Authentication实例。最后被存储进第一层过滤器的上下文信息当中

关于上下文,本质就是threadlocal,但是后就可以在上下文中获取用户id啦

4、更细节的信息:

认证管理器AuthenticationManager,实现类ProviderManager)自己无法完全完成认证的功能(一是因为他的功能不是提供认证,而是由他决定调用什么认证方式,以UP方式为例,2 主要是因为他没法直接获取用户所有信息),他底层是委托了认证服务提供方AuthenticationProvider 的实现类DaoAuthenticationProvider来实现的,

他怎么做的呢?

1、根据 UserDetailService 提供的接口 提供的UP查询数据库,返回该用户所有信息,也包括密码,

2、(密码)校验通过 后将用户的所有信息疯封装到响应 Authentication实例里面传给 认证管理器AuthenticationManager

Outh2协议

简单来说outh2协议就是专门用于第三方登录的。(为什么要用第三方登录?主要为了新网站推广,增大用户基数)

第三方登录的 “前提”

用户没有在本平台注册过,但是在第三方注册过;

主要有三个角色

用户、平台(我们)、第三方验证服务器(有用户的信息)第三方资源服务器

1、(用户同意)用户微信扫码,用户向(我们)平台申请,由于我们没有用户信息,申请访问转到第三方授认证服务(先申请授权码),

授权微服务询问用户是否愿意将自己的信息授权给 我们平台。用户同意

2、(获取授权码) 第三方给平台展示用户信息的过程并不是直接给,而是先下发给平台一个授权码

3、(获取令牌)平台通过授权码,向第三方获取令牌(证明)、平台端发放令牌

4、(获取资源) 平台通过令牌再向第三方资源服务器 申请用户信息

image-20230616090942736

image-20230616092655452

image-20230616093713284

本项目基于 SpringScurity 框架+ OAuth2 协议实现的用户单点登录。

OAuth2在本项目的应用

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

OAuth2的授权模式

  • Spring Security支持OAuth2认证,OAuth2提供授权码模式、密码模式、简化模式、客户端模式等四种授权模式。前面举的微信扫码登录的例子就是基于授权码模式。
  • 这四种模式中,授权码模式和密码模式应用较多,这里使用Spring Security演示授权码模式、密码模式。

授权码模式

  • 授权码模式简单理解就是使用授权码去获取令牌,要想获取令牌,首先要获取授权码,授权码的获取需要资源拥有者亲自授权同意才可以获取

image-20230616095316652

  1. 用户打开浏览器
  2. 通过浏览器访问客户端
  3. 通过浏览器想认证服务请求授权(用户扫描二维码)
    • 请求授权时会携带客户端的URL,此URL为下发授权码的重定向地址
  4. 认证服务向资源拥有者返回授权页面
  5. 资源拥有者亲自授权同意(用户点击同意登录
  6. 通过浏览器向认证服务发送授权同意
  7. 认证服务向客户端地址重定向,并携带授权码
  8. 客户端收到授权码
  9. 客户端携带授权码向认证服务申请令牌
  10. 认证服务向客户端颁发令牌
授权码模式测试
  • 要想测试授权码模式,首先要配置授权服务,即上图中的认证服务器,需要配置授权服务及令牌策略
  • 1、拷贝黑马提供的AuthorizationServer.java、TokenConfig.java到config包下

AuthorizationServerConfigurerAdapter要求配置以下几个类

  • AuthorizationServerSecurityConfigurer:用来配置令牌断点的安全约束
  • ClientDetailsServiceConfigurer:用来配置客户端详情服务
    • 随便一个客户端都可以随便接入到它的认证服务吗?答案是否定的,服务提供商会给批准接入的客户端一个身份,用于接入时的凭据,有客户端标识和客户端秘钥,在这里配置批准接入的客户端的详情信息
  • AuthorizationServerEndpointsConfigurer:用来配置令牌(token)的访问端点和令牌服务(token services)
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 {
    }
}

​ -

TokenConfig为令牌策略配置类

  • 暂时使用InMemoryTokenStore在内存存储令牌,令牌的有效期等信息配置如下
@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;
    }
}
  • 2、在WebSecurityConfig下配置 AuthenticationManager相关的bean

    @EnableWebSecurity
    @EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)
    public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    
    +    @Bean
    +    public AuthenticationManager authenticationManagerBean() throws Exception {
    +        return super.authenticationManagerBean();
    +    }
        ....
    

重启认证服务

get请求获取授权码,地址:

http://auth/oauth/authorize?client_id=XcWebApp&response_type=code&scope=all&redirect_uri=http://www.51xuecheng.cn

http://localhost:63070/auth/oauth/authorize?client_id=XcWebApp&response_type=code&scope=all&redirect_uri=http://www.51xuecheng.cn

image-20230616104044066

image-20230616104127970

选择all ,这时会返回一个code,这就是授权码。。。

(麻了,给我正糊涂了,授权码不应该是第三方认证服务给我i我们的吗)

。。。 oo 我好像有点懂了:

这里只是做了一个实验, 项目是第三方 的执行流程。。。。 就是接收到一个授权请求,就把授权码发给他,并且让他重定向指定网址?

image-20230616104154313

httpclient测试

### 授权码模式
### 第一步申请授权码(浏览器请求)

GET http://localhost:63070/auth/oauth/authorize?client_id=XcWebApp&response_type=code&scope=all&redirect_uri=http://www.51xuecheng.cn


### 第二步申请令牌
POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=authorization_code&code=18So1d&redirect_uri=http://www.51xuecheng.cn

注意,第二部申请的令牌需要时第一次请求申请到的授权码

成功获取令牌

image-20230616105829176

  • 说明
    1. access_token访问令牌,用于访问资源使用
    2. token_type:bearer是在RFC6750中定义的一种token类型,在携带令牌访问资源时,需要在head中加入bearer空格令牌内容
    3. refresh_token:当令牌快过期时使用刷新令牌,可以再次生成令牌
    4. expires_in:过期时间
    5. scope:令牌的权限范围,服务端可以根据令牌的权限范围去对令牌授权

令牌的作用:

令牌可以用于 我们后续媒体资源 访问权限的校验,令牌是由有效期的

密码模式

密码模式相对授权码模式简单,授权码模式需要借助浏览器供用户亲自授权,密码模式不用借助浏览器,如下图:

image-20230616112526673

1、资源拥有者提供账号和密码

2、客户端向认证服务申请令牌,请求中携带账号和密码

3、认证服务校验账号和密码正确颁发令牌。

密码模式测试
### 密码模式
POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=lisi&password=123

参数列表如下:

• client_id:客户端准入标识。

• client_secret:客户端秘钥。

• grant_type:授权类型,填写 password 表示密码模式 //授权码模式是 authorization_code

• username:资源拥有者用户名。

• password:资源拥有者密码。

image-20230616124916782

  • 这种模式十分简单,但是却意味着直接将用户敏感信息泄露给了client,因此说明这种模式只能用于client是我们自己开发的情况下

本项目的应用方式

  • 通过演示授权码模式和密码模式,授权码模式适合客户端和认证服务非同一个系统的情况,所以本项目采用授权码模式完成微信扫码认证,采用密码模式作为前端请求微服务的认证方式

JWT

普通令牌模式的缺陷

  • 客户端申请到令牌,接下来客户端携带令牌去访问资源,到资源服务器会校验令牌的合法性。
  • 资源服务器如何校验令牌的合法性?这里以OAuth2的密码模式为例进行说明

img

也即是每次我们的资源微服务想要校验令牌的合法性,都需要和 认证服务确认(5)。。。。emmm,dio他码的服了

执行性能低。

能不能让资源服务自己去校验呢?

image-20230616125743213

当然可以,令牌采用JWT格式即可解决上边的问题,用户认证通过后会得到一个JWT令牌,JWT令牌中已经包括了用户相关的信息,客户端只需要携带JWT访问资源服务,资源服务根据事先约定的算法自行完成令牌校验,无需每次都请求认证服务完成授权。

什么是JWT

为什么JWT令牌能让微服务自行校验呢?

JSON Web Token(JWT)是一种使用JSON格式传递数据的网络令牌技术

在讲JWT之前说一说传统的校验认证方式

传统有状态认证

用户登录成功将用户的身份信息存储在服务端

具体步骤如下:当用户访问应用服务,每个应用服务都会去 服务器查看session信息(主要是查看有没有这个用户的信息),如果session中没有该用户则说明用户没有登录,此时就会重新认证,而解决这个问题的方法是Session复制、Session黏贴。

一句话就是传统令牌,没有不包含用户信息,只拿一个令牌,配发判断登录的到底是谁,要想知道是谁还得看看已有的资料(session)里面有没有他

image-20230616130350694

JWT无状态认证

用户登录,认证服务通过给用户发令牌,

用户访问微服务直接用令牌访问,微服务直接校验令牌合法性

image-20230616130715066

jwt令牌之所以能世界验证的根本 原因就是可以自定义里面携带的信息(因为他长度很长。。。),这样服务单只要解码成功就可以获取令牌里面的用户信息了,不用再查seesion了,所以JWT令牌是一种无状态的认证方式

JWT令牌较长,占存储空间比较大,下面是一个JWT令牌的示例

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzMSJdLCJ1c2VyX25hbWUiOiJ6aGFuZ3NhbiIsInNjb3BlIjpbImFsbCJdLCJleHAiOjE2NjQyNTQ2NzIsImF1dGhvcml0aWVzIjpbInAxIl0sImp0aSI6Ijg4OTEyYjJkLTVkMDUtNGMxNC1iYmMzLWZkZTk5NzdmZWJjNiIsImNsaWVudF9pZCI6ImMxIn0.wkDBL7roLrvdBG2oGnXeoXq-zZRgE9IVV2nxd-ez_oA

JWT令牌由三部分组成,每部分中间使用点(.)分隔,例如xxxx.yyyyyy.zzzzzzz

  1. Header:第一部分是头部

    • 头部包括令牌的类型(即JWT)及使用的哈希算法(如HMAC、SHA256或RSA),一个例子如下

      {
          "alg": "HS256",
          "typ": "JWT"
      }
      • 将上面的内容使用Base64Url编码,得到一个字符串就是JWT令牌的第一部分
    • Payload:第二部分是负载,内容也是一个Json对象

      • 它是存放有效信息的地方,它可以存放JWT提供的现成字段,如iss(签发者)、exp(过期时间戳)、sub(面向的用户)等,也可以自定义字段
      • 此部分不建议存放敏感信息,因为此部分可以解码还原原始内容
      • 最后将第二部分负载使用Base64Url编码,得到一个字符串就是JWT令牌的第二部分
      {
          "sub": "1234567890",
          "name": "456",
          "admin": true
      }
  • Sugbature:第三部分是签名,此部分用于防止JWT内容被篡改。

    • 这个部分使用Base64Url将前两部分进行编码,编码后使用点(.)连接组成字符串,最后使用Header中声明的签名算法进行签名
    HMACSHA256(
        base64UrlEncode(header) + "." +
        base64UrlEncode(payload),
        secret)
    • base64UrlEncode(header):JWT令牌的第一部分
    • base64UrlEncode(payload):JWT令牌的第二部分
  • 为什么JWT可以防止篡改????

    • 第三部分使用签名算法对第一部分和第二部分的内容进行签名,常见的签名算法是HS526,常见的还有MD5、SHA等,签名算法需要使用密钥进行签名,密钥不对外公开,并且签名是不可逆的,如果第三方更改了内容,那么服务器验证前面就会失败,要想保证签名正确,必须保证内容、密钥与签名前一致

    image-20230616131924163

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

测试生成JWT令牌

没错,这里测试优势模拟的(第三方)认证服务,生成jwt令牌的过程

把下面的代码代替原来的 token config (覆盖原来生成普通令牌的方式,换成jwt令牌生成)

@Configuration
public class TokenConfig {

    private String SIGNING_KEY = "mq123"; // 私钥

    @Autowired
    TokenStore tokenStore;

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

    @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;
    }
}

配置好之后重启服务

模拟第三方生成令牌(密码模式)

image-20230617103911705

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:令牌的唯一标识。

令牌中的数据一般都是可以公开的数据,就算解密可对用户信息造成不了太大影响那种(如用户昵称、用户头像等等,用base64可以还原。绝对不可以包含用户密码!!!)

我们可以通过check_token接口校验jwt令牌

###校验jwt令牌
POST {{auth_host}}/auth/oauth/check_token?token= 刚才得到的令牌

image-20230617105631523

上面的校验本质上还是回到了 认证服务来 校验,我们学习 jwt 令牌的目的就是,不要再返回这里进行校验了

那么怎么能让微服务自己就能校验呢?

测试资源服务校验令牌

拿到了JWT令牌下一步就要携带令牌去访问资源服务中的资源,本项目各个微服务就是资源服务,例如:内容管理服务,当客户端申请到JWT令牌,携带JWT去内容管理服务查询课程信息,此时内容管理服务需要对JWT进行校验,只有JWT合法才可以继续访问,如下图

img

导入依赖

将资源用SpringSecurity+oauth2框架管控

再内容管理服务,的api工程(content-api)导入如下依赖

<!--认证相关-->
<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>

配置类

在内容管理服务的content-api中添加TokenConfig、以及ResourceServerConfig配置类

TokenConfig

@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());
    }
}

你会发现,

1、这里的密钥和之前认证系统的密钥是一样的,(所谓对称密钥嘛。)

2、 这里的tokenconfig也是jwt的方式

ResourceServerConfig

@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)
public class ResouceServerConfig extends ResourceServerConfigurerAdapter {


 //资源服务标识
 public static final String RESOURCE_ID = "xuecheng-plus";

 @Autowired
 TokenStore tokenStore;

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

@Override
public void configure(HttpSecurity http) throws Exception {
 http.csrf().disable()
         .authorizeRequests()
         .antMatchers("/r/**","/course/**").authenticated()//所有/r/**的请求必须认证通过
         .anyRequest().permitAll()
 ;
}

}

1、这里的资源名称RESOURCE_ID = “xuecheng-plus”; 与之前 auth工程里面的认证服务配置,资源列表.resourceIds(“xuecheng-plus”)//资源列表 相对应

2、.antMatchers(“/r/“,”/course/**“).authenticated()//所有/r/**的请求必须认证通过 标识所有以“/r/” 打头的资源,以及所有以“/course/” 打头的资源都需要认证,除了他们的就全部放行

测试

重启内容管理服务

使用httpclient测试:

1、访问根据课程id查询课程接口

### 查询课程信息
GET http://localhost:63040/content/course/2

2、返回

{
  "error": "unauthorized",
  "error_description": "Full authentication is required to access this resource"
}

可见资源已经被管控,需要携带令牌才能访问资源

那么我们通过密码模式申请资源

### 密码模式
POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=lisi&password=456

得到令牌后,携带JWT令牌访问资源服务地址

GET {{content_host}}/content/course/160
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsieHVlY2hlbmctcGx1cyJdLCJ1c2VyX25hbWUiOiJLeWxlIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTY3ODQzOTMwOSwiYXV0aG9yaXRpZXMiOlsicDEiXSwianRpIjoiNTAxNDNiZTItOGM3ZC00MmUzLWEwNDMtMTQwMGQ5NWQ5MmZiIiwiY2xpZW50X2lkIjoiWGNXZWJBcHAifQ.o3nWLeRkJncEnnZ0egFmBpyC8Keq-L8IY6k0Uc0a96c

image-20230618163959645

成功访问

注意 Authorization: Bearer eyJhbGc。。。。。 是请求头里面的信息,Bearer 专门用于校验的(专门是oauth2 协议的标志)

从jwt令牌获取信息

原理!!!

JWT令牌中记录了用户身份信息(因为一般只有登录的用户才能获取资源),

之前我们说过 SpringSecurity框架是由一系列过滤器构成的,一部分过滤器用于获取用户信息,一部分就用于存储获取的用户信息将其存储在过滤器的上下文当中(底层基于threadlocal实现)

当客户端携带JWT访问资源服务,资源服务认证通过后,将两部分内容还原,即可取出用户的身份信息,之后并将用户身份信息放在了SecurityContextHolder上下文,SecurityContext与当前线程进行绑定,方便获取用户身份

image-20230618165920777

那么我们获取时,可以通过 以下方式获取(以一个查询课程的函数接口为例)

    @ApiOperation("根据课程id查询课程基础信息")
    @GetMapping("/course/{courseId}")
    public CourseBaseInfoDto getCourseBaseById(@PathVariable Long courseId){
+        // 获取当前用户身份信息
+        Object principal = SecurityContextHolder.getContext() //拿到contenxt
+                .getAuthentication() //  拿到认证信息
+                .getPrincipal();// 拿到身份
+        System.out.println(principal); // 看看打印出来的身份信息时什么样的

        return courseBaseInfoService.getCourseBaseInfo(courseId);
    }

重启内容管理服务,使用HttpClient测试接口,查看控制台是否会输出用户身份

lisi

网关认证

流程

现在捋一下整体的认证思路

image-20230618171232422

用户登录,进入统一认证入口,输入账号密码后获取jwt令牌,然后通过jwt令牌来访问我们的微服务

每个微服务上添加校验相关的依赖和配置就能进行校验以及获取用户信息。

emmm。也就是每个微服务为了能够参会与校验,都要进行相关的依赖和配置。。。是否太太冗余了呢?

是的,校验的话,其实可以交给我们的网关就好(注意时gateway,而不是 nignx)

那么流程就是这样了

image-20230618171713495

一个请求过来之后,nginx 转发到网关,网关来做统一的认证。

问题:

1、网关做了认证了,那微服务还需要jwt吗?

2、网关做了认证了,微服务还能获取到用户的身份吗?

这两个问题其实是一个问题,网关认证(校验合法性)通过后,他就起到一个请求转发的作用,依然携带jwt令牌将请求转发下去。

后续微服务便不再校验合法性了(不在认证了)

后续微服务来做授权

3、为什么不再网关就把认证和授权一起做了?

不是不行,而是很难,或者说不方便。

资源是在微服务的,只有微服务再清除,这些接口供哪那些人使用,网关只能通过微服务才知道权限分别

网关的作用

所以,综上。项目里网关的作用

1、路由转发、负载均衡

2、认证-校验合法性(没有授权!!!)

3、白名单维护(有些资源不需要身份,任何人都能访问,不需要校验令牌。如公开的课程)

再次注意:网关不负责授权,授权是在微服务做的

网关认证的实现

基本上还是继承SpringSecurity+Oauth2的过程

依赖

1、网关工程添加依赖

<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、拷贝课程资料下网关认证配置类到网关工程的config包下。

@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;
    }
}
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;
    }
}
@EnableWebFluxSecurity
@Configuration
public class SecurityConfig {
    //安全拦截配置
    @Bean
    public SecurityWebFilterChain webFluxSecurityFilterChain(ServerHttpSecurity http) {
        return http.authorizeExchange()
                .pathMatchers("/**").permitAll()
                .anyExchange().authenticated()
                .and().csrf().disable().build();
    }
}
@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;
    }
}

需要能看懂第一个网关的验证过程(过滤器逻辑)

1、放行白名单

2、检查令牌是否存在

3、检查令牌是否过期

4、检查令牌是否校验通过

那么由于这里网关对令牌做了一个统一的校验,那么后面我们内容服务原来的那个校验就不用了。注释掉

.antMatchers("/r/**","/course/**").authenticated()//所有/r/**的请求必须认证通过

白名单

把文件中的配置文件(有关白名单的)放进resources目录

/**=临时全部放行
/auth/**=认证地址
/content/open/**=内容管理公开访问接口
/media/open/**=媒资管理公开访问接口

解释一下

  • 所有认证相关的地址全部放行:部先认证后续怎么根据jwt校验?
  • 内容管理公开访问接口 :可公开的接口,全部人的可以访问
  • 媒资管理公开访问接口 同上

测试

重启网关

申请令牌

### 密码模式
POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=lisi&password=456

携带令牌获取资源

GET {{gateway_host}}/content/course/124
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsieHVlY2hlbmctcGx1cyJdLCJ1c2VyX25hbWUiOiJsaXNpIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTY4NzA5Mzk5OSwiYXV0aG9yaXRpZXMiOlsicDIiXSwianRpIjoiMDgxOTczZTYtNTE4NC00NzQ5LWE2MjUtNDdjMjIxNWMyNDMzIiwiY2xpZW50X2lkIjoiWGNXZWJBcHAifQ.JxvOAAL1DLEBT4j_R5z7xXSfqjZQev9TNtFsaj8e0MA

image-20230618191501558

成功!

用户认证(密码登录)

需求分析

以上演示了Spring Security 基本的功能,主要时作为第三方认证服务怎么生成 授权码、怎么生成令牌,怎么校验令牌,怎么统一做一个网关认证,这些点,

这一节,将在此基础上做进一步的补充

之前我们的用户名、密码是写死再代码里面的,这样肯定不行,到时候都是要从数据库里面读取的。

这一节中,我们将 用多种方式实现用户认证

包括

1、用户密码登录

2、验证码登录

3、微信扫码登录(第三方认证)

连接用户中心数据库

我们之前的用户名、密码直接写死在代码里面的

就写在 WebSecurityConfig 里面

让我们再看一眼

@Bean
public UserDetailsService userDetailsService() {
    //这里配置用户信息,这里暂时使用这种方式将用户存储在内存中
    InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
    manager.createUser(User.withUsername("zhangsan").password("123").authorities("p1").build());
    manager.createUser(User.withUsername("lisi").password("456").authorities("p2").build());
    return manager;
}

实际生产过程中必然不能这样写的,我们再一次回忆SpringSecurity 那个执行流程图

img

可见:

用一个认证请求过来时(3),送到我们SpringSecurity框架中的认证管理器Authentication Manager

认证管理器 本身没有认证的功能 但是可以选择 认证方式 ,如果他选择了 用本地的用户名密码比对的方式登录

他将委托 认证提供类 DaoAuthentication Provider 来 查询数据库(通过调用 UserDetailsService 接口的 loadUserByUsername来查询)

UserDetailsService 具体来说是一个接口

public interface UserDetailsService {
    UserDetails loadUserByUsername(String var1) throws UsernameNotFoundException;
}

接口中只有一个函数loadUserByUsername,顾名思义,就是通过用户名来获取完整的用户信息。(可能会包括密码,然后框架帮助我们进行密码比对?)

那么,我们想要实现从本地数据库通过用户名密码登录,那么就需要

1、屏蔽之前直接写死在代码里面的操作

2、 自己注入一个自定义的UserDetailService的实现类,重写他的loadUserByUsername方法,将其注入进websecurity配置类

UserDetailService实现

在auth工程下新建service以及impl,新建UserDetailServiceImpl类,

@Slf4j
@Component
public class UserDetailServiceImpl implements UserDetailsService {

    @Autowired
    XcUserMapper xcUserMapper;
    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        // 没别的意思,只是变量名看着舒服
        String name = s;
        // 根据username去XcUser表中查询对应的用户信息
        XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, name));
        // 返回NULL表示用户不存在,SpringSecurity会帮我们处理,框架抛出异常用户不存在
        if (user == null) {
            return null;
        }
        // 取出数据库存储的密码
        String password = user.getPassword();

        // 权限
        String[] authorities = {"test","public"};
        //如果查到了用户拿到正确的密码,最终封装成一个UserDetails对象给spring security框架返回,由框架进行密码比对
        UserDetails userDetails = User.withUsername(user.getUsername())
                .password(password)
                .authorities(authorities[0])
                .build();
        return userDetails;
    }
}

让我们看看SpringSecurity框架时怎么调用他的

image-20230618203132177

密码加密方式

刚刚写完了UserDetailService 的自定义实现,但是 如果你查看数据库的话,发现里面都不是以明文方式存储的密码,

而默认返回比对的方式时明文(WebSecurity配置的NoOpPasswordEncoder),也就是输入是明文,查到的数据库时明文,然后明文之间进行比对

那么怎么才能改成非铭文呢?更改一下之前WebSecurity里面的加密方式就行了

目前对密码的加密方式最流行的时 BCryptPasswordEncoder ,他有个神奇的特征 : 就算密码一样,如果你但你加密后的密码仍然不一样

我们更改WebSecurity配置里面的加密方式:

    @Bean
    public PasswordEncoder passwordEncoder() {
//        //密码为明文方式
//        return NoOpPasswordEncoder.getInstance();
        return new BCryptPasswordEncoder();
    }

可以测试一下加密效果

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

修改数据库中的密码为Bcrypt格式,并且记录明文密码,稍后申请令牌时需要。

由于修改密码编码方式还需要将客户端的密钥更改为Bcrypt格式.

@Override
public void configure(ClientDetailsServiceConfigurer clients)
        throws Exception {
    clients.inMemory()// 使用in-memory存储
            .withClient("XcWebApp")// client_id
-        	//.secret("XcWebApp")//客户端密钥
+            .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进行测试

### 密码模式
POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=lisi&password=456

笑死,当然查不到了,因为数据库里面没有啊

### 密码模式
POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=stu1&password=111111

image-20230618215528502

测试成功!

扩展用户信息

上一步我们可以从数据库验证用户是否存在,但是,如果你校验jwt令牌会发现,给的信息实在是太少了,只有一个姓名。这完全够用,后面我们有些地方还需要获取用户的机构id?呢。

怎么扩展呢?

校验的话,框架使用我们返回的userdetailserviceimpl 那个UserDetails 来校验的,这时框架写好的,看看里面有什么

public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();
    String getPassword();
    String getUsername();
    boolean isAccountNonExpired();
    boolean isAccountNonLocked();
    boolean isCredentialsNonExpired();
    boolean isEnabled();
}

emmmm有用的信息只有用户名和密码。。。

所以啊,我们要扩展整个类,以便在JWT令牌中存储用户的昵称、头像、QQ等信息

  • 如何扩展Spring Security的用户身份信息呢?

    • 在认证阶段DaoAuthenticationProvider会调用UserDetailsService查询用户的信息,这里是可以获取到齐全的用户信息。
    • 由于JWT令牌中用户身份信息来源于UserDetails,UserDetails中仅定义了username为用户的身份信息,这里有两个思路
      1. 扩展UserDetails,时期包括更多的自定义属性
      2. 扩展username的内容,例如存入Json数据作为username的内容
    • 相较而言,如果通过方案一的话就要该框架里面的内容了,比较麻烦;方案2比较简单,而且也不用破坏UserDetails的结构,这里采用方案二,
@Slf4j
@Component
public class UserDetailServiceImpl implements UserDetailsService {

    @Autowired
    XcUserMapper xcUserMapper;
    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        // 没别的意思,只是变量名看着舒服
        String name = s;
        // 根据username去XcUser表中查询对应的用户信息
        XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, name));
        // 返回NULL表示用户不存在,SpringSecurity会帮我们处理,框架抛出异常用户不存在
        if (user == null) {
            return null;
        }
        // 取出数据库存储的密码
        String password = user.getPassword();

+        // 注意用户敏感信息不要写到令牌里面
+        user.setPassword(null);
+        String userinfoString = JSON.toJSONString(user);


        // 权限
        String[] authorities = {"test","public"};
        //如果查到了用户拿到正确的密码,最终封装成一个UserDetails对象给spring security框架返回,由框架进行密码比对
-        UserDetails userDetails = User.withUsername(user.getUsername())
+		 UserDetails userDetails = User.withUsername(userinfoString)
                .password(password)
                .authorities(authorities[0])
                .build();
        return userDetails;
    }
}

测试

用密码模式获取令牌:

校验令牌看看有什么信息:

image-20230618222244568

那么后续就可以从jwt令牌里面获取用户相关的信息了

具体怎么获得呢,

1、通过SecurityContextHolder获取user_name

2、然后将其转换为XcUser对象即可

几乎每需要jwt里面信息的时候都要进行这些操作,不如。。写一个工具类

放在新建一个 utils包谁用谁掉

@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;
    }
}

测试:

    @ApiOperation("根据课程id查询课程基础信息")
    @GetMapping("/course/{courseId}")
    public CourseBaseInfoDto getCourseBaseById(@PathVariable Long courseId){
//        // 获取当前用户身份信息
//        Object principal = SecurityContextHolder.getContext() //拿到contenxt
//                .getAuthentication() //  拿到认证信息
//                .getPrincipal();// 拿到身份
//        System.out.println(principal); // 看看打印出来的身份信息时什么样的
+        SecurityUtil.XcUser user = SecurityUtil.getUser();
+        System.out.println(JSON.toJSONString(user));
        return courseBaseInfoService.getCourseBaseInfo(courseId);
    }
  1. 重启认证服务、内容管理服务
  2. 生成新的令牌
  3. 携带令牌访问内容管理服务的查询课程接口,控制台可以看到输入的用户信息

image-20230618224646409

成功解析

认证方式多样化

统一认证入口

前面说了,我们的项目上向实现

1、用户名密码登录

2、验证码登录

3、微信扫码登录

怎么才能统一在一起呢,只是前端统一到一个界面是不行的,不同的认证方式提交的数据也是不一样的

为了接收不同认证方式的数据,我们可以定义一个统一的认证参数接收DTO类型:

我们已经加过了,如下

统一接收参数

@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
}

之后传入的参数一律按照上面的json字符串的形式传入

@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();
    }
}

那么,接受的请求格式应该是这样的:

### 密码模式
POST localhost:53070/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username={"username":"stu1","password":"111111","authType":"password"}&password=111111

再想一想,刚刚我们重写的loadUserByUsername()方法是由DaoAuthenticationProvider调用的(也就是用户名密码模式),

在用户名密码模式下,我们在这个函数里面 返回查询到的用户名密码,然后DaoAuthenticationProvider 调用他的additionalAuthenticationChecks方法去校验

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"));
        }
    }
}

屏蔽密码比对

img

但是并非所有验证方式都需要校验密码。。。那么,怎么才能避免去校验密码呢?

答:重写DaoAuthenticationProvider即可,覆盖原来的additionalAuthenticationChecks方法(把这个方法写在config目录下)

@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

@Autowired
DaoAuthenticationProviderCustom daoAuthenticationProviderCustom;

@Override
protected void configure(AuthenticationManagerBuilder auth) {
    auth.authenticationProvider(daoAuthenticationProviderCustom);
}

重启认证服务,测试申请令牌,传入账号信息改为JSON数据,打个断点,看看传入的请求参数是否为JSON格式

### 密码模式
POST localhost:63070/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username={"username":"lisi","password":"456","authTpye":"password"}&password=456

image-20230619083921255

定义统一认证接口

  • 有了这些认证参数,我们可以定义一个统一的认证的Service接口去进行各种方式的认证,然后该Service的各种实现类来实现各种方式的认证
public interface AuthService {

   /**
    * @description 认证方法
    * @param authParamsDto 认证参数
    * @return com.xuecheng.ucenter.model.po.XcUser 用户信息
   */
   XcUserExt execute(AuthParamsDto authParamsDto);

}
  • 定义用户信息,为了可扩展性,我们让其继承XcUser
  • 这里最好不要直接用XcUser类,理由在之前的文章也说过,万一我们需要扩展一些其他的用户信息,那么我们直接修改XcUser类是不现实的,因为XcUser类对应的是数据库中的表。所以即使我们要使用XcUser类作为返回类型,也最好是让一个其他的类继承XcUser
@Data
public class XcUserExt extends XcUser {
    //用户权限
    List<String> permissions = new ArrayList<>();
}

好了,那么我们就可以用service来实现各种各样的 登陆的验证方式

用户名密码登录

一个接口的多种实现,我们依靠beanName来做区分,例如这里的password_authservice,见名知意就知道是密码登录方式

@Service("password_authservice")
public class PasswordAuthServiceImpl implements AuthService {

    @Override
    public XcUserExt execute(AuthParamsDto authParamsDto) {
        return null;
    }
}
微信扫码登录

这里的wx_authservice,一看就是微信扫码方式

@Service("wx_authservice")
public class WxAuthServiceImpl implements AuthService {
    @Override
    public XcUserExt execute(AuthParamsDto authParamsDto) {
        return null;
    }
}

修改loadUserByUsername()

根据不同的认证方式选择不同的认证Service实现类 主要通过拼接beanname获取认证的bena

    @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 userinfoString = JSON.toJSONString(user);
        //如果查到了用户拿到正确的密码,最终封装成一个UserDetails对象给spring security框架返回,由框架进行密码比对
        UserDetails userDetails = User.withUsername(userinfoString)
                .password(password)
                .authorities(authorities[0])
                .build();
        return userDetails;
    }

image-20230619090511093

实现用户名密码登录

上面我们只是简单定义了账号密码认证的实现类,并没有编写具体逻辑,那这个小节我们就来具体实现账号密码认证

@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 调用,我们返回的是xcUserExt 还需要再loadUserByUsername 封装成userdetail对象

修改loadUserByUsername()方法,我们可以将最后的封装UserDetails的相关代码抽取为一个方法

@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {

    // 将传入的json字符串转成dto对象
    AuthParamsDto authParamsDto =null;
    try{
        authParamsDto = JSON.parseObject(s, AuthParamsDto.class);
    }catch (Exception exception){
        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);

    // 将返回的 XcUserExt 封装成userdetails
    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;
}

申请令牌,注意JSON数据中要带上authType

成功!

image-20230619100505718

验证码服务

为啥那么有了密码还需要验证码?

主要是为了防止被攻击

  • 验证码可以防止恶性攻击,例如
    • XSS跨站脚本攻击
    • CSRF跨站请求伪造攻击
  • 为了保护系统的安全,在进行一些比较重要的操作时,都需要验证码,例如
    • 认证
    • 找回密码
    • 人机判断
    • 支付验证等
  • 验证码的类型也有很多:图片、语音、手机短信验证码等
  • 本项目创建单独的验证码服务微各业务提供验证码的生成、校验等服务

拷贝黑马提供的xuecheng-plus-checkcode验证码服务到自己的工程目录,修改bootstrap.yml

在nacos中新增checkcode-dev.yaml

image-20230620113643576

同时,验证码需要存储在redis 中,配置redis

新增路由相关配置

- id: auth-service
  uri: lb://auth-service
  predicates:
    - Path=/auth/**
- id: checkcode
  uri: lb://checkcode
  predicates:
    - Path=/checkcode/**

验证码是缓存在redis中

需要部署redis

用docker部署redis

docker pull redis
docker run -d --name myredis -p 6379:6379 redis
docker start myredis

md 吐了,docker崩了,重装了docker结果也还是崩,那大概率不行了,想买个服务器,看网上 8G服务器两百多一个月。。。额 好贵。算了

不行,妈的重新做系统吧,服了

干了一上午,白白浪费时间,服了 就这样吧

6月22日:还是买了阿里云的服务器(59.110.13.16),不过是按照量计费,估计能比包月买便宜不少。

配了一上午环境。。。。终于回复差不多了,装了docker/jdk/nacos/mysql/xxl-job,继续干活!

同时在nacos中配置redis-dev.yaml,group设置为xuecheng-plus-common

spring:
  redis:
    host: 59.110.13.16
    port: 6379
    password: 
    database: 0
    lettuce:
      pool:
        max-active: 20
        max-idle: 10
        min-idle: 0
    timeout: 10000

在验证码模块中引入redis的配置(已有)

+  - data-id: redis-${spring.profiles.active}.yaml
+    group: xuecheng-plus-common
+    refresh: true

在验证码模块中引入redis依赖(已有)

<!--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>

生成

image-20230622214846546

校验

image-20230622220339994

可以从redis 看到生成的key 以及 对应的答案

image-20230622220800610

测试成功

生成的东西一个是key,一个是验证码图片已经转成了base64编码

如果想预览直接输入浏览器即可

为啥那么有 图片就行了还要有key呢?

  • 答:主要用于校验
  • 当用户使用验证码登录时,一方面会发给前端验证码图片,还会发key(只不过被前端隐藏起来了,其实用用户获取到也没啥用,因为key只是用于标识这次请求的,不能通过他来获取验证码的答案。)
  • 在发送验证码给用户的同时还会将key 与答案 存在redis里面
  • 在用户提交验证码时(其实提交的是两个东西,一个是隐藏起来的key 一个是验证码的答案)

流程

验证码服务如何生成并校验验证码?

拿图片验证码举例:

1、先生成一个指定位数的验证码,根据需要可能是数字、数字字母组合或文字。

2、根据生成的验证码生成一个图片并返回给页面

3、给生成的验证码分配一个key,将key和验证码一同存入缓存。这个key和图片一同返回给页面。

4、用户输入验证码,连同key一同提交至认证服务。

5、认证服务拿key和输入的验证码请求验证码服务去校验

6、验证码服务根据key从缓存取出正确的验证码和用户输入的验证码进行比对,如果相同则校验通过,否则不通过。

image-20230622215933453

代码

先草草看一遍黑马提供的验证码服务,有个CheckCodeService是验证码接口,其内部还有一个CheckCodeStore接口,CheckCodeStore接口是负责存储验证码的

public interface CheckCodeService {

    CheckCodeResultDto generate(CheckCodeParamsDto checkCodeParamsDto);

    public boolean verify(String key, String code);

    public interface CheckCodeGenerator{
        String generate(int length);
    }
    public interface KeyGenerator{
        String generate(String prefix);
    }
    public interface CheckCodeStore{
        void set(String key, String value, Integer expire);
        String get(String key);
        void remove(String key);
    }
}

CheckCodeStore 主要参数是 key value 核过期时间

顺藤摸瓜,找到它的实现类为MemoryCheckCodeStore,现在我们只需要修改这个类,改为用Redis缓存验证码即可

@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);
    }
}

嗯嗯,大概流程就这样。。生生图片什么的就不看了

账号密码认证

验证码微服务

需求分析

到目前为止账号和密码认证所需要的技术、组件都已开发完毕,下边实现账号密码认证,输出如下图:

image-20230622223523922

img

我们随便输入输入 以及验证码看看请求时往那个服务发的

image-20230622224721656

发现正式我们之前写的那个统一认证入口申请令牌的那个函数

如果正确输入密码(t1 111111)以及验证码,则会将得到的令牌存入cookie

image-20230622225021007

若点击退出就是把 本地cookie清除的过程。

image-20230622225151029

输错密码

image-20230622232205228

可见密码错误可以识别

但是这里我们的验证码的识别还未实现

现在我们回头去校验验证码

校验验证码其实已经在验证码服务里面实现了,所以,只需在认证服务里面远程调用 验证码服务的接口即可。

远程调用校验服务

在认证服务定义远程调用验证码服务的接口

ucenter.feignclient

package com.xuecheng.ucenter.feignclient;

 @FeignClient(value = "checkcode",fallbackFactory = CheckCodeClientFactory.class)
 @RequestMapping("/checkcode")
public interface CheckCodeClient {

 @PostMapping(value = "/verify")
 public Boolean verify(@RequestParam("key") String key,@RequestParam("code") String code);

}

回顾一下 远程调用 openfeign的写法,首先把 定义接口

上面 加上 @FeignClient 注解

  • value值是服务名称:checkcode
  • fallbackFactory 是降级(工厂)方法

在类内部定义请求方法,与被调方保持一致

package com.xuecheng.ucenter.feignclient;

@Slf4j
@Component
public class CheckCodeClientFactory implements FallbackFactory<CheckCodeClient> {
    @Override
    public CheckCodeClient create(Throwable throwable) {
        return new CheckCodeClient() {

            @Override
            public Boolean verify(String key, String code) {
                log.debug("调用验证码服务熔断异常:{}", throwable.getMessage());
                return null;
            }
        };
    }
}

启动类上开启@EnableFeignClients

@EnableFeignClients(basePackages={"com.xuecheng.*.feignclient"})

因为引入熔断降级配置\ 以及pom文件依赖(已在)

- data-id: feign-${spring.profiles.active}.yaml
  group: xuecheng-plus-common
  refresh: true

调用

    @Autowired
    XcUserMapper xcUserMapper;

    @Autowired
    PasswordEncoder passwordEncoder;

+    @Autowired
+    CheckCodeClient checkCodeClient;

    @Override
    public XcUserExt execute(AuthParamsDto authParamsDto) {
+        // 0、校验验证码
+        // 远程调用验证码服务去校验验证码
+        String checkcode = authParamsDto.getCheckcode();
+        String checkcodekey = authParamsDto.getCheckcodekey();
+        if(checkcode==null || checkcodekey==null|| StringUtils.isEmpty(checkcode)){
+            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;
    }

重启服务,看看验证码能不能保证生效。

image-20230622235859811

拦截成功

如果输入正确

image-20230622235936383

登陆成功!

微信登录(第三方登录)

技术背景

微信提供的第三方登录接口是基于Oauth2的授权码模式,也就是1、用户同意 2、先申请授权码,3、再拿授权码申请令牌

接口文档

https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html

前提,我们作为第三方引用,需要去微信开发平台申请相关权限,一般以公司的名义去申请,需要有一个网站、一个域名,等等,来申请的参数 如appid等 重定向地址 等

image-20230623003304941

    1. 第三方发起微信授权登录请求,微信用户允许授权第三方应用后,微信会拉起应用或重定向到第三方网站,并且带上授权临时票据 code 参数;
    2. 通过 code 参数加上 AppID 和AppSecret等,通过 API 换取access_token;
    3. 通过access_token进行接口调用,获取用户基本数据资源或帮助用户实现基本操作。

通过查看官方文档发现,(我们应用平台)获取授权码可以有两种方式

1、通过 一个 连接 来先获取授权码 ;

https://open.weixin.qq.com/connect/qrconnect?
appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=SCOPE&state=STATE#wechat_redirect

微信申请授权码需要如下参数,其中最重要的就是appid 和重定向地址

参数 是否必须 说明
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

用户允许授权后,将会重定向到redirect_uri的网址上,并且带上 code 和state参数

redirect_uri?code=CODE&state=STATE

2、通过让用户扫描一个二维码(微信提供),扫完,微信会将授权码发给我们

自然第二种方案(对用户而言)最简单,我们只需将二维码生成的代码(js,http://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js)引入到我们的前端,注意二维码并不是不是我们生成的,而是微信收到我们前端的那些申请参数自动生成的

我们可以在静态资源wxsign.html (前端工程中)发现页面引入了这个函数

image-20230623005027995

本质上这两种方案都一样;

准备开发环境

那么怎么才能获得上面的这些申请验证码/令牌的参数呢?

我上面也提及到了,一般是企业以项目名义等方式来申请。

需要注册微信开放平台,并且为你的项目指备案后注册外网域名(到时候指定这个域名作为微信登录的回调地址),emmm这里,我肯定做不到了。。。。

  • 审核通过后微信会给你分配一个appID 和 AppSecret

准备内网穿透工具

用户扫码后,会以重定向的方式把授权码给我们。

微信是在公网,而现在我们的后端处于开发环境,微信怎么才能定位到我们呢?

  • 答:需要中间使用一个内网穿透的服务器
  • image-20230623151444433

  • 微信访问的应该是我们的认证服务端口,所以是63070

当后续我们脱离了开发环境,微信就能直接通过公网来访问我们的认证服务,这样就不需要内网穿透工具了。

为什么需要配置呢?

主要是因为黑马没有给我们对应的 appid 以及serect 只能自己去申请,但是个人开发者很难申请到这些东西

以上两个配置方案都需要相当的时间,我在课程评论区发现了一个可以绕过这些申请、认证的方式(不知道具体的原理是什么,大概意思是使用了尚硅谷提供的一些appid以及secret,总之先试试把)

步骤

  • 1、(auth-server.yaml中配置) 这个端口改为8160,添加appid 和 appsecret、
server:
  servlet:
    context-path: /auth
  port: 8160

weixin:
  appid: wxed9954c01bb89b47
  secret: a7482517235173ddb4083788de60b90e

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://59.110.13.16:3306/xc_users?serverTimezone=UTC&userUnicode=true&useSSL=false&
    username: root
    password: root
  • 2、更改nginx配置
server {
    listen 8160;
    server_name localhost;

    location /api {
	proxy_pass http://gatewayserver;
	#proxy_pass http://localhost:63010;
	proxy_set_header Host $host;
	proxy_set_header X-Real-IP $remote_addr;
	proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

	# 这里需要添加一个rewrite规则,把请求中的/api去掉
	rewrite ^/api(.*)$ $1 break;
    }
}
  • 3、更改原来扫码js脚本
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: ""
    });

image-20230623160047435

二维码从“天机学堂”变成了“我的谷粒”

接收授权码/微信验证登录

做到这里,相当于实现了下面这些步骤

image-20230623160906826

当用户扫完点同意后,微信就会我们重定向地址以及授权码。

image-20230623161420183

那么我们就

1、需要定义接口接收微信下发的授权码

2、接收到授权码后,拿授权码去微信申请令牌

3、拿到令牌后去微信资源服务器查询用户信息,

4、保存用户信息进我们的数据库,

5、如果这个用户真的存在

​ 就让用户(自动)登录(这个controller 返回 重定向地址,重定向到登录网址)并给他发放我们给他的jwt令牌

接口定义

按照上述接口,定义WxLoginController类

可以定义一个wx的service 去 执行1-4 步骤,执行完返回用户信息

@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);
        // 1-4 步 :全部在service 实现里面执行
        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("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, "t1"));
        //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;
    }
}

// 其实完整的功能还没有搭建完成,我们先试试能不能正常跑通

扫码后点击同意,成功重定向到 微信 给我们的 授权码,我们接收授权码的接口

image-20230623164225704

假设我们通过1-4步获取到完整的用户信息,返回controller接口

image-20230623164345297

最终,controller接口会将 让前端的页面重定向到登录接口,

image-20230623165024535

那么,走的相当于原来定义的统一的认证接口

等价于我们测试时的

POST localhost:63070/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&username={"username":"t1","password":"","authType":"wx"}

SpringSecurity 接收到这个请求后,和上一节一样,

img

  1. (1、2)用户提交用户名、认证方式等信息被SecurityFilterChain中的UsernamePasswordAuthenticationFilter过滤器获取到,封装为请求Authentication,通常情况下是UsernamePasswordAuthenticationToken这个实现类。

  2. (3)然后过滤器将请求Authentication提交至认证管理器(AuthenticationManager)进行认证

    2.1 认证管理器认证管理器 本身没有认证的功能 但是可以选择 认证方式 这里展示了 通过默认方式 去认证也就是委托 认证提供类 DaoAuthentication Provider 的 loadUserByUsername 来 查询/验证 数据库 返回完整的用户数据信息,验证通过后返回

    2.2 我们重写DaoAuthentication Provider这个类 重新实现loadUserByUsername 以及 里面的 校验方法 execute

  3. (9)认证成功后,AuthenticationManager身份管理器返回一个被填充满了信息的(包括上面提到的权限信息,身份信息,细节信息,但密码通常会被移除)Authentication实例。最后被存储进第一层过滤器的上下文信息当中

逻辑实现

远程调用中如果通过HTTP协议来调用

如果是在我们自己的微服务之间,我们采用的是Feign

如果是自己调用第三方的微服务,那么我们采用的是RestTemplate

这里我们申请第三方获取令牌,明显就是与第三方的HTTP交互,所以,采用restTemplate的方式

使用restTemplate

前提

1、使用RestTemplate请求微信,在AuthApplication里配置RestTemplate的bean

需要在启动类注入RestTemplate的bean(第三方的Bean都需要在配置类或者启动类中注入成bean)

@Bean
RestTemplate restTemplate(){
    RestTemplate restTemplate = new RestTemplate(new OkHttp3ClientHttpRequestFactory());
    return  restTemplate;
}

提取方法

2、定义一个WxAuthService,将刚刚写的wxAuth()方法提取到该接口中

public interface WxAuthService {
    XcUser wxAuth(String code);
}

让WxAuthService实现WxAuthService,这样代码更规范,没别的意思,其实没有多少实际意义

@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;
    }
    @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;
    }
}

获取令牌

在WxAuthServiceImpl类中定义申请令牌的私有方法,

我们把这个方法单独提取出来

看看微信官方怎么说明需要的参数的

请求参数

GET https://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code
参数 是否必须 说明
appid 应用唯一标识,在微信开放平台提交应用审核通过后获得
secret 应用密钥 AppSecret,在微信开放平台提交应用审核通过后获得
code 填写第一步获取的 code 参数
grant_type 填 authorization_code

enn需要这些个玩意。

将以json返回下面这些东西

{
  "access_token": "ACCESS_TOKEN",
  "expires_in": 7200,
  "refresh_token": "REFRESH_TOKEN",
  "openid": "OPENID",
  "scope": "SCOPE",
  "unionid": "UNIONID"
}
参数 说明
access_token 接口调用凭证
expires_in access_token 接口调用凭证超时时间,单位(秒)
refresh_token 用户刷新 access_token
openid 授权用户唯一标识
scope 用户授权的作用域,使用逗号(,)分隔
unionid 用户统一标识。针对一个微信开放平台帐号下的应用,同一用户的 unionid 是唯一的

// 还是在WxAuthServiceImpl 里面

    @Autowired
    RestTemplate restTemplate;

    @Value("${weixin.appid}") 
    String appid;

    @Value("${weixin.secret}")
    String secret;
// 上面这俩黑马都没有提供给我们,我们利用的尚硅谷的。。。。,太感谢了!!

    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;
    }

啊啊啊啊啊啊 真相了,原来 post也可以在url传参呐。。。。。我一直以为只能在 请求体里面传参。

测试

打个断点看一下,看看是否能拿到access_token

@Override
public XcUser wxAuth(String code) {
    //获取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;
}

发现突然启动不了 niginx了

错误信息如下

2023/06/23 22:01:42 [emerg] 17128#23268: bind() to 0.0.0.0:8160 failed (10013: An attempt was made to access a socket in a way forbidden by its access permissions)
2023/06/23 22:03:17 [notice] 13608#3736: signal process started
2023/06/23 22:03:17 [error] 13608#3736: OpenEvent("Global\ngx_reload_24488") failed (2: The system cannot find the file specified)

网上大概意思是说8160被占用了?,我尼玛就是要用啊 他是验证服务端口啊,要不先启动ngnix再启动服务试试?

还是不行。。。

emmmm

看看端口

netstat -aon | findstr :80

  TCP    [2001:da8:215:3c0a:8f3f:ca64:32d5:787d]:14404  [2001:da8:215:3c0a:8f3f:ca64:32d5:787d]:8160  TIME_WAIT       0

把这个线程关了试试?

tasklist|findstr “14404”


没输出任何玩意啊。。。

麻了。。。

网上说退出一下就行,输入了一下以下代码,好了。。。。什么鬼?

nginx -s quit
start nginx

开始测试!

emmmmm 又遇到新的问题了,开启nginx后,微服务有报错了

Action:

Identify and stop the process that's listening on port 8160 or configure this application to listen on another port.

意思大概说 8160 已经被占用了,这个多半是因为nignx设置了一个监听,好像不用也可以,我试试?

把原来这里的注释掉

#     server {
# 	    listen 8160;
# 	    server_name localhost;

# 	    location /api {
# 		proxy_pass http://gatewayserver;
# 		#proxy_pass http://localhost:63010;
# 		proxy_set_header Host $host;
# 		proxy_set_header X-Real-IP $remote_addr;
# 		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

# 		# 这里需要添加一个rewrite规则,把请求中的/api去掉
# 		rewrite ^/api(.*)$ $1 break;
# 	    }
# 	}

啊终于好了。。。

开始测试!!!

image-20230623222747204

成功获取令牌(access_token!)

获取用户信息

同样的我们把这一步提取成一个方法

看看微信发放平台上怎么获取信息的?

请求参数

GET https://api.weixin.qq.com/sns/userinfo?access_token=ACCESS_TOKEN&openid=OPENID
参数 是否必须 说明
access_token 调用凭证
openid 普通用户的标识,对当前开发者帐号唯一
lang 国家地区语言版本,zh_CN 简体,zh_TW 繁体,en 英语,默认为 en

响应为

{
  "openid": "OPENID",
  "nickname": "NICKNAME",
  "sex": 1,
  "province": "PROVINCE",
  "city": "CITY",
  "country": "COUNTRY",
  "headimgurl": "https://thirdwx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/0",
  "privilege": ["PRIVILEGE1", "PRIVILEGE2"],
  "unionid": " o6_bmasdasdsad6_2sgVt7hMZOPfL"
}
参数 说明
openid 普通用户的标识,对当前开发者帐号唯一
nickname 普通用户昵称
sex 普通用户性别,1 为男性,2 为女性
province 普通用户个人资料填写的省份
city 普通用户个人资料填写的城市
country 国家,如中国为 CN
headimgurl 用户头像,最后一个数值代表正方形头像大小(有 0、46、64、96、132 数值可选,0 代表 640*640 正方形头像),用户没有头像时该项为空
privilege 用户特权信息,json 数组,如微信沃卡用户为(chinaunicom)
unionid 用户统一标识。针对一个微信开放平台帐号下的应用,同一用户的 unionid 是唯一的
private Map<String,String> getUserInfo(String access_token,String openid) {

    String wxUrl_template = "https://api.weixin.qq.com/sns/userinfo?access_token=%s&openid=%s";
    //请求微信地址
    String wxUrl = String.format(wxUrl_template, access_token,openid);

    log.info("调用微信接口申请access_token, url:{}", wxUrl);

    ResponseEntity<String> exchange = restTemplate.exchange(wxUrl, HttpMethod.POST, null, String.class);

    //防止乱码进行转码!!!
    String result = new     String(exchange.getBody().getBytes(StandardCharsets.ISO_8859_1),StandardCharsets.UTF_8);
    log.info("调用微信接口申请access_token: 返回值:{}", result);
    Map<String,String> resultMap = JSON.parseObject(result, Map.class);

    return resultMap;
}

测试

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

    // 获取用户信息
+    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;
}

成功

image-20230623224740819

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

image-20230623224818582

保存用户信息

  • 向数据库保存用户信息,如果用户不存在,则将其保存在数据库
  • 在WxAuthServiceImpl中定义方法addWxUser()
@Autowired
XcUserRoleMapper xcUserRoleMapper;

@Transactional
public XcUser addWxUser(Map userInfo_map){
    String unionid = userInfo_map.get("unionid").toString();
    //根据unionid查询数据库
    XcUser xcUser = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getWxUnionid, unionid));
    if(xcUser!=null){
        return xcUser;
    }
    String userId = UUID.randomUUID().toString();
    xcUser = new XcUser();
    xcUser.setId(userId);
    // 微信上的id
    xcUser.setWxUnionid(unionid);
    //记录从微信得到的昵称
    xcUser.setNickname(userInfo_map.get("nickname").toString());
    xcUser.setUserpic(userInfo_map.get("headimgurl").toString());
    xcUser.setName(userInfo_map.get("nickname").toString());
    xcUser.setUsername(unionid);
    xcUser.setPassword(unionid);
    xcUser.setUtype("101001");//学生类型
    xcUser.setStatus("1");//用户状态
    xcUser.setCreateTime(LocalDateTime.now());
    xcUserMapper.insert(xcUser);
    XcUserRole xcUserRole = new XcUserRole();
    xcUserRole.setId(UUID.randomUUID().toString());
    xcUserRole.setUserId(userId);
    xcUserRole.setRoleId("17");//学生角色
    xcUserRoleMapper.insert(xcUserRole);
    return xcUser;
}
public XcUser wxAuth(String code) {
    //申请令牌即获取access_token
    Map<String, String> access_token_map = getAccess_token(code);
    String accessToken = access_token_map.get("access_token");
    if(StringUtils.isEmpty(accessToken)) return null;
    //获取用户信息
    String openid = access_token_map.get("openid");
    if(StringUtils.isEmpty(openid)) return null;
    Map<String, String> userInfo = getUserInfo(accessToken, openid);
     //添加用户信息到数据库   非事务方法调用事务方法
    XcUser xcUser = currentProxy.addWxUser(userInfo);
    return xcUser;
}
  • 注意:非事务方法调用事务方法,要使用代理对象调用,前面也提到过这点
  • 这里的addWxUser()方法涉及到了多表操作,所以需要进行事务控制,而wxAuth()是非事务方法,所以这里我们需要注入自身,然后调用addWxUser()

Controller层接口代码如下

@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";
}

重启服务,扫码登录测试

报错了

image-20230623235353542

看日志,至少到 获取用户信息这一块都是正常的

应该是保存用户信息这一步出错了

报错的意思大概是检测到\xF0\x9F\x8D\x91\ 字符非法 ,估计是我的昵称是 桃图案导致的 哈哈哈哈哈

网上说,可以改一改数据库的的编码 ,改成 utf8mb4 即可,试试

因为有外键约束,改动总不行,我把外键删了试试,一般好像也不设置外键,因为外键约束很影响数据库的性能

还是不行

好像是JDBC不支持utf8mb4

>spring.datasource.type=org.apache.commons.dbcp2.BasicDataSource
>spring.datasource.dbcp2.connection-init-sqls=SET NAMES utf8mb4

这回好像成功啦

image-20230624010431297

不过好像又遇到问题了

image-20230624010741944

发生了跨域。。。。

再看看细节

image-20230624012048624

还是没能解决,好困啊,先睡了明天再看看

第二天:发现浏览器有弹出了一个禁止重定向的选框,我们将其反选

这个“缩小”bug展示解决了,但是好像还有问题:

1、我们的controller 重定向到”redirect:http://localhost/sign.html?username="+username+"&authType=wx“;

2、接就是又走了 那一套 Spring Security 的流程,我们在UserDetailServiceImpl 里面的loadUserByUsername 里面打个断点

设一下断点看看

image-20230624105616549

目前loadUserByUsername 接收倒是没啥问题,后面到了验证的时候 即进入WxAuthServiceImpl 的execute 方法

执行完应该也没问题

image-20230624105909154

image-20230624110251026

到这里也没问题

image-20230624110832163

暂时都没问题啊,问什么就是不显示呢?

试试密码登录到这:

image-20230624111641399

username:

>{
   "companyId":"1232141425",
   "createTime":"2022-09-28T08:32:03",
   "id":"52",
   "name":"M老师",
   "permissions":[],
   "sex":"1",
   "status":"",
   "username":"t1",
   "utype":"101002"
>}

而微信

>{
   "createTime":"2023-06-24T11:19:37",
   "id":"e913f158-c8be-4658-8eed-8fbf59fe8f30",
   "name":"🍑🍑",
   "nickname":"🍑🍑",
   "permissions":[],
   "status":"1",
   "username":"oWgGz1EYHi0Oq_hprfCcboiNgiHA",
   "userpic":"https://thirdwx.qlogo.cn/...",
   "utype":"101001",
   "wxUnionid":"oWgGz1EYHi0Oq_hprfCcboiNgiHA"
>}

前端还是报错

>{
   "timestamp": "2023-06-24T03:35:03.818+00:00",
   "path": "/auth/oauth/token",
   "status": 503,
   "error": "Service Unavailable",
   "message": "",
   "requestId": "2e873f4c-67"
>}

这也就相当于输入:

>"redirect:http://localhost/sign.html?username="+username+"&authType=wx";

>http://localhost/api/auth/oauth/token?username=%7B%22username%22%3A%22oWgGz1EYHi0Oq_hprfCcboiNgiHA%22%2C%22password%22%3A%22%22%2C%22checkcode%22%3A%22%22%2C%22checkcodekey%22%3A%22%22%2C%22authType%22%3A%22wx%22%7D&grant_type=password&client_secret=XcWebApp&client_id=XcWebApp

这里应该是调用了http://localhost/static/js/page-learing-sign.js

const loginSubmit = (params) => {
     return  requestPostForm('/api/auth/oauth/token?'+params,{});
 }

我自测时正常的啊….

>POST localhost:8160/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username={"username":"oWgGz1EYHi0Oq_hprfCcboiNgiHA","authType":"wx"}

image-20230624122043328

可以正常获取令牌啊。。为什么还是不行呢?

崩溃了,忙活了一上午,还没明白

卧槽终于成功了!!!!!

image-20230624141711110

我把回调的地址改成了

>@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();
   log.debug("执行完微信登录获取用户信息,下面验证用户信息,username:{},authType:{}",username,"wx");
>+    return "redirect:http://www.51xuecheng.cn/sign.html?username="+username+"&authType=wx";
>}

原先一直没法存上令牌的原因可能是 存的位置不对?

其实本质原理还是没搞懂,只是瞎猫碰着死耗子了,有时间一定研究研究…好累

用户授权

RBAC

  • 如何实现授权?业界通常基于RBAC实现授权

  • RBAC分为两种方式

  • 基于角色的访问控制(Role-Based Access Control)

    基于资源的访问控制(Resource-Based Access Control)

  1. 基于角色的访问控制(Role-Based Access Control)

    • 按角色进行授权,例如:只有主体角色为总经理,才可以查询企业运营报表,查询员工工资等,其授权代码可以表示如下

      if(主体.hasRole("总经理角色ID")){
          //TODO: 查询报表
          //TODO: 查询工资
      }
    • 但是如果现在的需求是:总经理和部门经理都可以查询报表和工资,那么此时就需要修改逻辑判断

      if(主体.hasRole("总经理角色ID") || 主体.hasRole("部门经理角色ID")){
          //TODO: 查询报表
          //TODO: 查询工资
      }
    • 此种方式当需要修改角色权限时,就需要修改授权相关的代码,系统可扩展性差

  2. 基于资源的访问控制(Resource-Based Access Control)

    • 按资源(或权限)进行授权,例如:用户必须具有查询工资的权限才可以查询员工工资,其授权代码可以表示如下

      if(主体.hasPermission("查询工资权限标识")){
          //TODO: 查询工资
      }
    • 优点:系统设计时定义好查询工资的权限标识,即使查询工资所需要的角色变化为总经理和部门经理,也不需要修改授权代码,系统可扩展性强

    • 所以现如今一般嗾使基于资源的访问控制

资源服务授权流程

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

配置nginx代理

#前端开发服务
upstream uidevserver{
    server 127.0.0.1:8601 weight=10;
} 
server {
    listen       80;
    server_name  teacher.51xuecheng.cn;
    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/;

    }   
}
  • 这里配完了之后要是访问教学机构的时候,teacher.51xuecheng.cn;,的访问路径

权限管理

在资源服务集成Spring Security

  • 在需要授权的接口处使用@PreAuthorize("hasAuthority('权限标识符')")进行控制
  • 下面代码指定/course/list接口需要拥有xc_teachmanager_course_list权限
@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;
}

启动 content system auth gateway 以及 8601前端

image-20230624151434608

由于配置了统一的异常处理,所以,前端并不知到发生了什么,我们从后端才知道权限判定生效了

那么为了让前端知道是权限不足导致的,我们需要把这个异常捕获,返回到前端

照理说应该捕获 AccessDeniedException 但是 统一异常处理在base包下,

  • 如果向捕获AccessDeniedException 就必须引入SpringSecurtiy依赖,,,这样Base工程就在SpringSecurity管控之下了,而且因为其他所有的 服务都依赖于 Base工程,那么其他所有微服务都将在SpringSecuroty的管控之下
  • 所以不需要引入依赖
  • 在原来的通用异常打一个补丁,看看里面的信息是不是 不允许访问
@ResponseBody
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public RestErrorResponse exception(Exception e) {

   log.error("【系统异常】{}",e.getMessage(),e);
   e.printStackTrace();
   if(e.getMessage().equals("不允许访问")){
      return new RestErrorResponse("没有操作此功能的权限");
   }
   return new RestErrorResponse(CommonError.UNKOWN_ERROR.getErrMessage());


}

image-20230624153817834

我们肯定会很好奇,当对接口进行资源管控时,他是怎么校验的呢?

校验权限的流程

首先微服务引入了SpringSecurity的依赖

以及相关配置信息(ResouceServerConfig、TokenConfig)

当我们访问接口时,是携带令牌去访问的

发现了一个bug 必须先运行nignx 后运行的话 会报错,无法代理,,这时为社么?

image-20230624175903811

image-20230624180339553

微服务获取令牌,解开令牌时,还附带了令牌的权限

image-20230624180534516

这个test时让是我们封装userdetail时加的,如果想要赋予他某某权限需要封装时 自己去添加即可

授权相关的数据模型(用户/角色/权限)

对于用户的授权,简单来说就是封装userdetail 给他再权限数组中增加相关权限即可,但是如何能够侵入性小的方式添加/删除权限呢?

这要从 用户/ 角色 /权限表 说起

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

image-20230624182028830

  • 本项目要求掌握基于权限模型数据,要求在数据库中操作完成给用户分配权限、查询用户权限等需求

用户权限查询

如果向申请令牌是给这个令牌上注入权限,那么 首先肯定是向数据库查询这个用户的权限

查询语句应该是(查询id为51的用户他的权限)

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' 
    )
)

定义mapper接口(已经有了)

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);
}

将权限“注入”UserDetail

修改UserServiceImpl类的 loadUserPassword getUserPrincipal方法,查询权限信息

    public UserDetails getUserPrincipal(XcUserExt user) {
        // 查询用户权限
+        String[] authorities = {"test"};
+        List<XcMenu> xcMenus = xcMenuMapper.selectPermissionByUserId(user.getId());
+        if(xcMenus.size()>0){
+            List<String> permissions = new ArrayList<>();
+            xcMenus.forEach(item->{
+                permissions.add(item.getCode());
+            });
+            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;
    }

测试:访问成功!

image-20230624185929031

细粒度授权

  • 什么叫细粒度授权?

    • 细粒度授权也叫数据范围授权,即不同的用户所拥有的的操作权限相同,但是能够操作的数据范围是不一样的。
    • 例如:用户A和网易的,用户B是字节的,他们都拥有我的课程的权限,但他们查询到的数据是不一样的,因为不能查询别的机构的课程
  • 本项目有哪些细粒度授权?

    • 我的课程:教学机构只允许查询本机构下的课程信息
    • 我的选课:学生只允许查询自己所选的课
  • 如何实现细粒度授权?

    • 细粒度授权涉及到不同的业务逻辑,通常在service层实现,根据不同的用户进行校验,根据不同的参数查询不同的数据,或操作不同的数据

教学机构细粒度授权

  • 教学机构在维护课程时,只允许维护本机构的课程,教学机构细粒度授权过程如下
    1. 获取当前登录的用户身份 通过SpringSecurity 的函数 SecurityUtil.getUser(); // 本质从上下文过滤器获取
    2. 得到用户所属教育机构的id
    3. 查询该教学机构下的课程信息
  • 最终实现了用户只允许查询自己机构的课程信息
  • 在之前的做法,我们是模拟了一个假数据,用的是一个写死的companyId
  • 根据companyId查询课程,流程如下
    1. 教学机构用户登录系统,从用户身份中取出所属机构的id
    2. 接口层取出当前登录用户的身份,取出机构id
    3. 将机构id传入service方法
    4. service方法将机构id传入dao方法,作为SQL查询参数(where companyId = ${companyId}),最终查询出本机构的课程信息
@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;
}

因为该机构下没什么课程所以搜不到

image-20230624210655729

实战

找回密码(实战)

  • 由于邮件发送比手机验证码要方便的多,所以这里就只做邮件注册、找回密码了
  • 需求:忘记密码需要找回,可以通过邮箱找回密码
  • 页面访问地址:localhost/findpassword.html

需求分析

  • 邮箱验证码:/api/checkcode/phone?param1=电子邮箱地址
  • 找回密码:/api/auth/findpassword

请求

{
    "cellphone":'',
    "email":'',
    "checkcodekey":'',
    "checkcode":'',
    "confirmpwd":'',
    "password":''
}
  • 执行流程
    1. 校验验证码,不一致则抛异常
    2. 判断两次密码是否一致,不一致则抛异常
    3. 根据邮箱查询用户
    4. 如果找到用户,更新其密码

导入依赖

邮箱

在checkcode模块中导入邮件发送相关依赖(之前瑞吉外卖那篇文章也用过的)

<!-- 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>

redis依赖

<!--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>

编写邮箱工具类

package com.xuecheng.checkcode.utils;


public class MailUtil {
    public static void main(String[] args) throws MessagingException {
        //可以在这里直接测试方法,填自己的邮箱即可
        sendTestMail("1359114644@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", "1359114644@qq.com");
        // 此处填写16位STMP口令
        props.put("mail.password", "gdkoqdggj******b");
        // 构建授权信息,用于进行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);
    }
}

接口定义

在Controller层中添加对应的接口

@ApiOperation(value = "发送邮箱验证码", tags = "发送邮箱验证码")
@PostMapping("/phone")
public void sendEMail(@RequestParam("param1") String email) {

}

@ApiOperation(value = "找回密码", tags = "找回密码")
@PostMapping("/findpassword")
public void findPassword(@RequestBody FindPswDto findPswDto) {
    
}

定义FindPswDto类,用于接收找回密码的参数信息

@Data
@NoArgsConstructor
@AllArgsConstructor
public class FindPswDto {
    String cellphone;
    String email;
    String checkcodekey;
    String checkcode;
    String password;
    String confirmpwd;
}

service

public interface SendCodeService {
    void sendEMail(String email, String code);
}
public interface VerifyService {
    void findPassword(FindPswDto findPswDto);
}

实现

@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);
    }
}
@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 = picCheckCodeServiceImpl.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);
    }
}

完善

@Autowired
SendCodeService sendCodeService;

@ApiOperation(value = "发送邮箱验证码", tags = "发送邮箱验证码")
@PostMapping("/phone")
public void sendEMail(@RequestParam("param1") String email) {
    String code = MailUtil.achieveCode();
    sendCodeService.sendEMail(email, code);
}
@ApiOperation(value = "找回密码", tags = "找回密码")
@PostMapping("/findpassword")
public void findPassword(@RequestBody FindPswDto findPswDto) {
    verifyService.findPassword(findPswDto);
}

注册

需求分析

  • 需求:为学生提供注册入口,通过此入口注册的用户为学生用户
  • 页面访问地址:localhost/register.html

接口

  • 邮箱验证码:/api/checkcode/phone?param1=邮箱
  • 注册:/api/auth/register
{
    "cellphone":'',
    "username":'',
    "email":'',
    "nickname":'',
    "password":'',
    "confirmpwd":'',
    "checkcodekey":'',
    "checkcode":''
}
  • 执行流程
    1. 校验验证码,不一致,抛异常
    2. 校验两次密码是否一致,不一致,抛异常
    3. 校验用户是否存在,已存在,抛异常
    4. 向用户表、用户关系角色表添加数据,角色为学生

接口定义


@ApiOperation(value = "注册", tags = "注册")
@PostMapping("/register")
public void register(@RequestBody RegisterDto registerDto) {
    
}

准备一个Dto类,接收注册请求的参数

@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;

}

Service

void register(RegisterDto registerDto);

实现

@Override
@Transactional
public void register(RegisterDto registerDto) {
    String uuid = UUID.randomUUID().toString();
    String email = registerDto.getEmail();
    String checkcode = registerDto.getCheckcode();
    Boolean verify = picCheckCodeServiceImpl.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("新增用户角色信息失败");
    }
}

完善

@ApiOperation(value = "注册", tags = "注册")
@PostMapping("/register")
public void register(@RequestBody RegisterDto registerDto) {
    verifyService.register(registerDto);
}

重启服务,进行测试,观察数据库中是否有对应的注册用户信息

卧槽,终于整理完毕了,感觉还有很多地方不太明白,改天做一个 关于Spring Security 和 jwt 相关的总结