微服务实战项目-学成在线-认证授权模块

1 模块需求分析

1.1 什么是认证授权

截至目前,项目已经完成了课程发布功能,课程发布后用户通过在线学习页面点播视频进行学习。如何去记录学生的学习过程呢?要想掌握学生的学习情况就需要知道用户的身份信息,记录哪个用户在什么时间学习什么课程,如果用户要购买课程也需要知道用户的身份信息。所以,去管理学生的学习过程最基本的要实现用户的身份认证。

认证授权模块实现平台所有用户的身份认证与用户授权功能。

什么是用户身份认证?

用户身份认证即用户去访问系统资源时系统要求验证用户的身份信息,身份合法方可继续访问。常见的用户身份认证的表现形式有:用户名密码登录,微信扫码等方式。

项目包括学生、学习机构的老师、平台运营人员三类用户,不管哪一类用户在访问项目受保护资源时都需要进行身份认证。比如:发布课程操作,需要学习机构的老师首先登录系统成功,然后再执行发布课程操作。创建订单,需要学生用户首先登录系统,才可以创建订单。如下图:


什么是用户授权?

用户认证通过后去访问系统的资源,系统会判断用户是否拥有访问资源的权限,只允许访问有权限的系统资源,没有权限的资源将无法访问,这个过程叫用户授权。比如:用户去发布课程,系统首先进行用户身份认证,认证通过后继续判断用户是否有发布课程的权限,如果没有权限则拒绝继续访问系统,如果有权限则继续发布课程。如下图:

1.2 业务流程

1.2.1 统一认证

项目包括学生、学习机构的老师、平台运营人员三类用户,三类用户将使用统一的认证入口,如下图:


用户输入账号和密码提交认证,认证通过则继续操作。

项目由统一认证服务受理用户的认证请求,如下图:


认证通过由认证服务向给用户颁发令牌,相当于访问系统的通行证,用户拿着令牌去访问系统的资源。

1.2.2 单点登录

本项目基于微服务架构构建,微服务包括:内容管理服务、媒资管理服务、学习中心服务、系统管理服务等,为了提高用户体验性,用户只需要认证一次便可以在多个拥有访问权限的系统中访问,这个功能叫做单点登录。

引用百度百科:单点登录(Single Sign On),简称为
SSO,是目前比较流行的企业业务整合的解决方案之一。SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。

如下图,用户只需要认证一次,便可以在多个拥有访问权限的系统中访问。

1.2.3 第三方认证

为了提高用户体验,很多网站有扫码登录的功能,如:微信扫码登录、QQ扫码登录等。扫码登录的好处是用户不用输入账号和密码,操作简便,另外一个好处就是有利于用户信息的共享,互联网的优势就是资源共享,用户也是一种资源,对于一个新网站如果让用户去注册是很困难的,如果提供了微信扫码登录将省去用户注册的成本,是一种非常有效的推广手段。

微信扫码登录其中的原理正是使用了第三方认证,如下图:

2 Spring Security 认证研究

2.1 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

Spring cloud Security: https://spring.io/projects/spring-cloud-security

2.2 认证授权入门

2.2.1 创建认证服务工程

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

1、部署认证服务工程

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

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

此工程不具备认证授权的功能。

2、创建数据库

创建xc_users数据库

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


在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/xc1010_users?serverTimezone=UTC&userUnicode=true&useSSL=false&
    username: root
    password: mysql

初始工程自带了一个Controller类,如下:

package com.xuecheng.auth.controller;


/**
 * @author Mr.M
 * @version 1.0
 * @description 测试controller
 * @date 2022/9/27 17:25
 */
@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资源";
  }



}

启动工程,尝试访问http://localhost:63070/auth/r/r1 :


访问用户信息:http://localhost:63070/auth/user/52


以上测试一切正常说明此工程部署成功。

2.2.2 认证测试

下边向auth认证工程集成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>

重启工程,访问http://localhost:63070/auth/r/r1

自动进入/login登录页面,/login是spring
security提供的,此页面有几个css样式加载会稍微慢点,如下图:


账号和密码是多少呢?下一步需要进行安全配置。

拷贝课程资料下的WebSecurityConfig.java到config下需要三部分内容:

1、用户信息

在内存配置两个用户:zhangsan、lisi

zhangsan用户拥有的权限为p1

lisi用户拥有的权限为p2

2、密码方式

暂时采用明文方式

3、安全拦截机制

/r/**开头的请求需要认证

登录成功到成功页面

代码如下:

//配置用户信息服务
@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;
}

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

    //配置安全拦截机制
    @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、访问http://localhost:63070/auth/user/52 可以正常访问

2、访问http://localhost:63070/auth/r/r1 显示登录页面

账号zhangsan,密码为123,如果输入的密码不正确会认证失败,输入正确显示登录成功。

为什么http://localhost:63070/auth/user/52
可以正常访问,访问http://localhost:63070/auth/r/r1 显示登录页面?

http.logout().logoutUrl(“/logout”);配置了退出页面,认证成功后访问/logout可退出登录。

2.2.3 授权测试

用户认证通过去访问系统资源时spring
security进行授权控制,判断用户是否有该资源的访问权限,如果有则继续访问,如果没有则拒绝访问。

下边测试授权功能:

1、配置用户拥有哪些权限。

在WebSecurityConfig类配置zhangsan拥有p1权限,lisi拥有p2权限。

    @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、指定资源与权限的关系。

什么是系统的资源?

比如:查询一个用户的信息,用户信息就是系统的资源,要访问资源需要通过URL,所以我们在controller中定义的每个http的接口就是访问资源的接口。

下边在controller中配置/r/r1需要p1权限,/r/r2需要p2权限。

hasAuthority(‘p1’)表示拥有p1权限方可访问。

代码如下:

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

现在重启工程。

当访问以/r/开头的url时会判断用户是否认证,如果没有认证则跳转到登录页面,如果已经认证则判断用户是否具有该URL的访问权限,如果具有该URL的访问权限则继续,否则拒绝访问。

例如:

访问/r/r1,使用zhangsan登录可以正常访问,因为在/r/r1的方法上指定了权限p1,zhangsan用户拥有权限p1,所以可以正常访问。

访问/r/r1,使用lisi登录则拒绝访问,由于lisi用户不具有权限p1需要拒绝访问

注意:如果访问上不加@PreAuthorize,此方法没有授权控制。

整理授权的过程见下图所示:

2.2.4 工作原理

通过测试认证和授权两个功能,我们了解了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作为Bean被Spring管理,它们是Spring
Security核心,各有各的职责,但他们并不直接处理用户的认证,也不直接处理用户的授权,而是把它们交给了认证管理器(AuthenticationManager)和决策管理器(AccessDecisionManager)进行处理。

spring Security功能的实现主要是由一系列过滤器链相互配合完成。


下面介绍过滤器链中主要的几个过滤器及其作用:

SecurityContextPersistenceFilter
这个Filter是整个拦截过程的入口和出口(也就是第一个和最后一个拦截器),会在请求开始时从配置好的
SecurityContextRepository 中获取 SecurityContext,然后把它设置给
SecurityContextHolder。在请求完成后将 SecurityContextHolder 持有的
SecurityContext 再保存到配置好的 SecurityContextRepository,同时清除
securityContextHolder 所持有的 SecurityContext;

UsernamePasswordAuthenticationFilter
用于处理来自表单提交的认证。该表单必须提供对应的用户名和密码,其内部还有登录成功或失败后进行处理的
AuthenticationSuccessHandler 和
AuthenticationFailureHandler,这些都可以根据需求做相关改变;

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

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

Spring Security的执行流程如下:


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

然后过滤器将Authentication提交至认证管理器(AuthenticationManager)进行认证

认证成功后,AuthenticationManager身份管理器返回一个被填充满了信息的(包括上面提到的权限信息,身份信息,细节信息,但密码通常会被移除)Authentication实例。

SecurityContextHolder安全上下文容器将第3步填充了信息的Authentication,通过SecurityContextHolder.getContext().setAuthentication(…)方法,设置到其中。

可以看出AuthenticationManager接口(认证管理器)是认证相关的核心接口,也是发起认证的出发点,它的实现类为ProviderManager。而Spring
Security支持多种认证方式,因此ProviderManager维护着一个List<AuthenticationProvider>列表,存放多种认证方式,最终实际的认证工作是由AuthenticationProvider完成的。咱们知道web表单的对应的AuthenticationProvider实现类为DaoAuthenticationProvider,它的内部又维护着一个UserDetailsService负责UserDetails的获取。最终AuthenticationProvider将UserDetails填充至Authentication。

2.3 什么是OAuth2

2.3.1 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版本已得到广泛应用。

参考:https://baike.baidu.com/item/oAuth/7153134?fr=aladdin

Oauth协议:https://tools.ietf.org/html/rfc6749

下边分析一个Oauth2认证的例子,黑马程序员网站使用微信认证扫码登录的过程:


具体流程如下:

1、用户点击微信扫码

用户进入黑马程序的登录页面,点击微信的图标开打微信扫码界面。



微信扫码的目的是通过微信认证登录黑马程序员官网,黑马程序员网站需要从微信获取当前用户的身份信息才会让当前用户在黑马网站登录成功。

现在搞清楚几个概念:

资源:用户信息,在微信中存储。

资源拥有者:用户是用户信息资源的拥有者。

认证服务:微信负责认证当前用户的身份,负责为客户端颁发令牌。

客户端:客户端会携带令牌请求微信获取用户信息,黑马程序员网站即客户端,黑马网站需要在浏览器打开。

2、用户授权黑马网站访问用户信息

资源拥有者扫描二维码表示资源拥有者请求微信进行认证,微信认证通过向用户手机返回授权页面,如下图:


询问用户是否授权黑马程序员访问自己在微信的用户信息,用户点击"确认登录"表示同意授权,微信认证服务器会颁发一个授权码给黑马程序员的网站。

只有资源拥有者同意微信才允许黑马网站访问资源。

3、黑马程序员的网站获取到授权码

4、携带授权码请求微信认证服务器申请令牌

此交互过程用户看不到。

5、微信认证服务器向黑马程序员的网站响应令牌

此交互过程用户看不到。

6、黑马程序员网站请求微信资源服务器获取资源即用户信息。

黑马程序员网站携带令牌请求访问微信服务器获取用户的基本信息。

7、资源服务器返回受保护资源即用户信息

8、黑马网站接收到用户信息,此时用户在黑马网站登录成功。

理解了微信扫码登录黑马网站的流程,接下来认识Oauth2.0的认证流程,如下:

引自Oauth2.0协议rfc6749 https://tools.ietf.org/html/rfc6749


Oauth2包括以下角色:

1、客户端

本身不存储资源,需要通过资源拥有者的授权去请求资源服务器的资源,比如:手机客户端、浏览器等。

上边示例中黑马网站即为客户端,它需要通过浏览器打开。

2、资源拥有者

通常为用户,也可以是应用程序,即该资源的拥有者。

A表示 客户端请求资源拥有者授权。

B表示 资源拥有者授权客户端即黑马网站访问自己的用户信息。

3、授权服务器(也称认证服务器)

认证服务器对资源拥有者进行认证,还会对客户端进行认证并颁发令牌。

C 客户端即黑马网站携带授权码请求认证。

D认证通过颁发令牌。

4、资源服务器

存储资源的服务器。

E表示客户端即黑马网站携带令牌请求资源服务器获取资源。

F表示资源服务器校验令牌通过后提供受保护资源。

2.3.2 OAuth2在本项目的应用

Oauth2是一个标准的开放的授权协议,应用程序可以根据自己的要求去使用Oauth2,本项目使用Oauth2实现如下目标:

1、学成在线访问第三方系统的资源。

本项目要接入微信扫码登录所以本项目要使用OAuth2协议访问微信中的用户信息。

2、外部系统访问学成在线的资源 。

同样当第三方系统想要访问学成在线网站的资源也可以基于OAuth2协议。

3、学成在线前端(客户端) 访问学成在线微服务的资源。

本项目是前后端分离架构,前端访问微服务资源也可以基于OAuth2协议进行认证。

2.3.3 OAuth2的授权模式

Spring
Security支持OAuth2认证,OAuth2提供授权码模式、密码模式、简化模式、客户端模式等四种授权模式,前边举的微信扫码登录的例子就是基于授权码模式,这四种模式中授权码模式和密码模式应用较多,本节使用Spring
Security演示授权码模式、密码模式,其余两种请自行查阅相关资料。

2.3.3.1 授权码模式

OAuth2的几个授权模式是根据不同的应用场景以不同的方式去获取令牌,最终目的是要获取认证服务颁发的令牌,最终通过令牌去获取资源。

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

下图是授权码模式的交互图:


还以黑马网站微信扫码登录为例进行说明:

1、用户打开浏览器。

2、通过浏览器访问客户端即黑马网站。

3、用户通过浏览器向认证服务请求授权,请求授权时会携带客户端的URL,此URL为下发授权码的重定向地址。

4、认证服务向资源拥有者返回授权页面。

5、资源拥有者亲自授权同意。

6、通过浏览器向认证服务发送授权同意。

7、认证服务向客户端地址重定向并携带授权码。

8、客户端即黑马网站收到授权码。

9、客户端携带授权码向认证服务申请令牌。

10、认证服务向客户端颁发令牌。

2.3.3.2授权码模式测试

要想测试授权模式首先要配置授权服务器即上图中的认证服务器,需要配置授权服务及令牌策略。

1、从课程资料中拷贝
AuthorizationServer.java、TokenConfig.java到认证服务的config包下。

说明":AuthorizationServer用 @EnableAuthorizationServer
注解标识并继承AuthorizationServerConfigurerAdapter来配置OAuth2.0
授权服务器。

package com.xuecheng.auth.config;
*/
 @Configuration
 @EnableAuthorizationServer
 public class AuthorizationServer extends AuthorizationServerConfigurerAdapter {
 ...
 

AuthorizationServerConfigurerAdapter要求配置以下几个类:

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

1)ClientDetailsServiceConfigurer:用来配置客户端详情服务(ClientDetailsService),

随便一个客户端都可以随便接入到它的认证服务吗?答案是否定的,服务提供商会给批准接入的客户端一个身份,用于接入时的凭据,有客户端标识和客户端秘钥,在这里配置批准接入的客户端的详细信息。

2)AuthorizationServerEndpointsConfigurer:用来配置令牌(token)的访问端点和令牌服务(token
services)。

3)AuthorizationServerSecurityConfigurer:用来配置令牌端点的安全约束.

2、TokenConfig为令牌策略配置类

暂时先使用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

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

    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
    ....

重启认证服务

1、get请求获取授权码

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

参数列表如下:

client_id:客户端准入标识。

response_type:授权码模式固定为code。

scope:客户端权限。

redirect_uri:跳转uri,当授权码申请成功后会跳转到此地址,并在后边带上code参数(授权码)。

输入账号zhangsan、密码123登录成功,输入/oauth/authorize?client_id=XcWebApp&response_type=code&scope=all&redirect_uri=http://www.51xuecheng.cn

显示授权页面


授权"XcWebApp"访问自己的受保护资源?

选择同意。

2、请求成功,重定向至http://www.51xuecheng.cn/?code=授权码,比如:http://www.51xuecheng.cn/?code=Wqjb5H

3、使用httpclient工具post申请令牌

/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=authorization_code&code=授权码&redirect_uri=http://www.51xuecheng.cn/

参数列表如下

client_id:客户端准入标识。

client_secret:客户端秘钥。

grant_type:授权类型,填写authorization_code,表示授权码模式

code:授权码,就是刚刚获取的授权码,注意:授权码只使用一次就无效了,需要重新申请。

redirect_uri:申请授权码时的跳转url,一定和申请授权码时用的redirect_uri一致。

httpclient脚本如下:

### 授权码模式
### 第一步申请授权码(浏览器请求)/oauth/authorize?client_id=c1&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=CTvCrB&redirect_uri=http://www.51xuecheng.cn

申请令牌成功如下所示:

{
  "access_token": "368b1ee7-a9ee-4e9a-aae6-0fcab243aad2",
  "token_type": "bearer",
  "refresh_token": "3d56e139-0ee6-4ace-8cbe-1311dfaa991f",
  "expires_in": 7199,
  "scope": "all"
}

说明:

1、access_token,访问令牌,用于访问资源使用。

2、token_type,bearer是在RFC6750中定义的一种token类型,在携带令牌访问资源时需要在head中加入bearer
空格 令牌内容

3、refresh_token,当令牌快过期时使用刷新令牌可以再次生成令牌。

4、expires_in:过期时间(秒)

5、scope,令牌的权限范围,服务端可以根据令牌的权限范围去对令牌授权。

2.3.3.3 密码模式

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


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

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

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

开始测试:

1、POST请求获取令牌

/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username=shangsan&password=123

参数列表如下:

client_id:客户端准入标识。

client_secret:客户端秘钥。

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

username:资源拥有者用户名。

password:资源拥有者密码。

2、授权服务器将令牌(access_token)发送给client

使用httpclient进行测试

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

返回示例:

{
  "access_token": "368b1ee7-a9ee-4e9a-aae6-0fcab243aad2",
  "token_type": "bearer",
  "refresh_token": "3d56e139-0ee6-4ace-8cbe-1311dfaa991f",
  "expires_in": 6806,
  "scope": "all"
}

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

2.3.3.4 本项目的应用方式

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

2.4 JWT

2.4.1 普通令牌的问题

客户端申请到令牌,接下来客户端携带令牌去访问资源,到资源服务器将会校验令牌的合法性。

资源服务器如何校验令牌的合法性?

我们以OAuth2的密码模式为例进行说明:


从第4步开始说明:

1、客户端携带令牌访问资源服务获取资源。

2、资源服务远程请求认证服务校验令牌的合法性

3、如果令牌合法资源服务向客户端返回资源。

这里存在一个问题:

就是校验令牌需要远程请求认证服务,客户端的每次访问都会远程校验,执行性能低。

如果能够让资源服务自己校验令牌的合法性将省去远程请求认证服务的成本,提高了性能。如下图:


如何解决上边的问题,实现资源服务自行校验令牌。

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

2.4.2 什么是JWT

什么是JWT?

json Web
Token(JWT)是一种使用json格式传递数据的网络令牌技术,它是一个开放的行业标准(RFC
7519),它定义了一种简洁的、自包含的协议格式,用于在通信双方传递json对象,传递的信息经过数字签名可以被验证和信任,它可以使用HMAC算法或使用RSA的公钥/私钥对来签名,防止内容篡改。官网:https://jwt.io/

使用JWT可以实现无状态认证,什么是无状态认证?

传统的基于session的方式是有状态认证,用户登录成功将用户的身份信息存储在服务端,这样加大了服务端的存储压力,并且这种方式不适合在分布式系统中应用。

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


如果是基于令牌技术在分布式系统中实现认证则服务端不用存储session,可以将用户身份信息存储在令牌中,用户认证通过后认证服务颁发令牌给用户,用户将令牌存储在客户端,去访问应用服务时携带令牌去访问,服务端从jwt解析出用户信息。这个过程就是无状态认证。


JWT令牌的优点:

1、jwt基于json,非常方便解析。

2、可以在令牌中自定义丰富的内容,易扩展。

3、通过非对称加密算法及数字签名技术,JWT防止篡改,安全性高。

4、资源服务使用JWT可不依赖认证服务即可完成授权。

缺点:

1、JWT令牌较长,占存储空间比较大。

下边是一个JWT令牌的示例:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzMSJdLCJ1c2VyX25hbWUiOiJ6aGFuZ3NhbiIsInNjb3BlIjpbImFsbCJdLCJleHAiOjE2NjQyNTQ2NzIsImF1dGhvcml0aWVzIjpbInAxIl0sImp0aSI6Ijg4OTEyYjJkLTVkMDUtNGMxNC1iYmMzLWZkZTk5NzdmZWJjNiIsImNsaWVudF9pZCI6ImMxIn0.wkDBL7roLrvdBG2oGnXeoXq-zZRgE9IVV2nxd-ez_oA

JWT令牌由三部分组成,每部分中间使用点(.)分隔,比如:xxxxx.yyyyy.zzzzz

Header

头部包括令牌的类型(即JWT)及使用的哈希算法(如HMAC SHA256或RSA)

一个例子如下:

下边是Header部分的内容

   {
    "alg": "HS256",
    "typ": "JWT"
  }

将上边的内容使用Base64Url编码,得到一个字符串就是JWT令牌的第一部分。

Payload

第二部分是负载,内容也是一个json对象,它是存放有效信息的地方,它可以存放jwt提供的信息字段,比如:iss(签发者),exp(过期时间戳),
sub(面向的用户)等,也可自定义字段。

此部分不建议存放敏感信息,因为此部分可以解码还原原始内容。

最后将第二部分负载使用Base64Url编码,得到一个字符串就是JWT令牌的第二部分。

一个例子:

  {
    "sub": "1234567890",
    "name": "456",
    "admin": true
  }

Signature

第三部分是签名,此部分用于防止jwt内容被篡改。

这个部分使用base64url将前两部分进行编码,编码后使用点(.)连接组成字符串,最后使用header中声明的签名算法进行签名。

一个例子:

  HMACSHA256(
    base64UrlEncode(header) + "." +
    base64UrlEncode(payload),
    secret)

base64UrlEncode(header):jwt令牌的第一部分。

base64UrlEncode(payload):jwt令牌的第二部分。

secret:签名所使用的密钥。

为什么JWT可以防止篡改?

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


从上图可以看出认证服务和资源服务使用相同的密钥,这叫对称加密,对称加密效率高,如果一旦密钥泄露可以伪造jwt令牌。

JWT还可以使用非对称加密,认证服务自己保留私钥,将公钥下发给受信任的客户端、资源服务,公钥和私钥是配对的,成对的公钥和私钥才可以正常加密和解密,非对称加密效率低但相比对称加密非对称加密更安全一些。

2.4.3 测试生成JWT令牌

在认证服务中配置jwt令牌服务,即可实现生成jwt格式的令牌,

package com.xuecheng.auth.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices;
import org.springframework.security.oauth2.provider.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.InMemoryTokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;

import java.util.Arrays;

/**
 * @author Administrator
 * @version 1.0
 **/
@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;
    }
}

重启认证服务。

使用httpclient通过密码模式申请令牌

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

生成jwt的示例如下:

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzMSJdLCJ1c2VyX25hbWUiOiJ6aGFuZ3NhbiIsInNjb3BlIjpbImFsbCJdLCJleHAiOjE2NjQzMzE2OTUsImF1dGhvcml0aWVzIjpbInAxIl0sImp0aSI6ImU5ZDNkMGZkLTI0Y2ItNDRjOC04YzEwLTI1NmIzNGY4ZGZjYyIsImNsaWVudF9pZCI6ImMxIn0.-9SKI-qUqKhKcs8Gb80Rascx-JxqsNZxxXoPo82d8SM",
  "token_type": "bearer",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzMSJdLCJ1c2VyX25hbWUiOiJ6aGFuZ3NhbiIsInNjb3BlIjpbImFsbCJdLCJhdGkiOiJlOWQzZDBmZC0yNGNiLTQ0YzgtOGMxMC0yNTZiMzRmOGRmY2MiLCJleHAiOjE2NjQ1ODM2OTUsImF1dGhvcml0aWVzIjpbInAxIl0sImp0aSI6ImRjNTRjNTRkLTA0YTMtNDIzNS04MmY3LTFkOWZkMmFjM2VmNSIsImNsaWVudF9pZCI6ImMxIn0.Wsw1Jc-Kd_GFqEugzdfoSsMY6inC8OQsraA21WjWtT8",
  "expires_in": 7199,
  "scope": "all",
  "jti": "e9d3d0fd-24cb-44c8-8c10-256b34f8dfcc"
}

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令牌

###校验jwt令牌
POST {{auth_host}}/oauth/check_token?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzMSJdLCJ1c2VyX25hbWUiOiJzdHUxIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTY2NDM3MTc4MCwiYXV0aG9yaXRpZXMiOlsicDEiXSwianRpIjoiZjBhM2NkZWItMzk5ZC00OGYwLTg4MDQtZWNhNjM4YWQ4ODU3IiwiY2xpZW50X2lkIjoiYzEifQ.qy46CSCJsH3eXWTHgdcntZhzcSzfRQlBU0dxAjZcsUw

响应示例如下:


{
  "aud": [
    "res1"
  ],
  "user_name": "zhangsan",
  "scope": [
    "all"
  ],
  "active": true,
  "exp": 1664371780,
  "authorities": [
    "p1"
  ],
  "jti": "f0a3cdeb-399d-48f0-8804-eca638ad8857",
  "client_id": "c1"
}
2.4.4 携带令牌访问资源服务

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


1、在内容管理服务的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>

2、从资料中拷贝 资源服务配置类 中的 TokenConfig和ResouceServerConfig
到内容管理的api工程的config包下。

可以在ResouceServerConfig类中配置需要认证的url。

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

重启内容管理服务

使用httpclient测试:

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

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

返回:


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

从返回信息可知当前没有认证。

下边携带JWT令牌访问接口:

1、申请jwt令牌

采用密码模式申请令牌。

2、携带jwt令牌访问资源服务地址

### 携带token访问资源服务
GET http://localhost:63040/content/course/2
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzMSJdLCJ1c2VyX25hbWUiOiJ6aGFuZ3NhbiIsInNjb3BlIjpbImFsbCJdLCJleHAiOjE2NjQzMzM0OTgsImF1dGhvcml0aWVzIjpbInAxIl0sImp0aSI6IjhhM2M2OTk1LWU1ZGEtNDQ1Yy05ZDAyLTEwNDFlYzk3NTkwOSIsImNsaWVudF9pZCI6ImMxIn0.73eNDxTX5ifttGCjwc7xrd-Sbp_mCfcIerI3lGetZto

在请求头中添加Authorization,内容为Bearer
令牌,Bearer用于通过oauth2.0协议访问资源。

如果携带jwt令牌且jwt正确则正常访问资源服务的内容。

如果不正确则报令牌无效的错误:

{
  "error": "invalid_token",
  "error_description": "Cannot convert access token to json"
}
2.4.5 测试获取用户身份

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

还以查询课程接口为例,进入查询课程接口的代码中,添加获取用户身份的代码

@ApiOperation("根据课程id查询课程基础信息")
@GetMapping("/course/{courseId}")
public CourseBaseInfoDto getCourseBaseById(@PathVariable("courseId") Long courseId){
    //取出当前用户身份
    Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    System.out.println(principal);
    return courseBaseInfoService.getCourseBaseInfo(courseId);
}

测试时需要注意:

1、首先在资源服务配置中指定安全拦截机制
/course/开头的请求需要认证,即请求/course/{courseId}接口需要携带jwt令牌且签证通过。

2、认证服务生成jwt令牌将用户身份信息写入令牌,目前还是将用户信息硬编码并暂放在内存中。

如下:

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

3、我们在使用密码模式生成jwt令牌时用的是zhangsan的信息,所以jwt令牌中存储了zhangsan的信息,那么在资源服务中应该取出zhangsan的信息才对。

清楚了以上内容,下边重启内容管理服务,跟踪取到的用户身份是否正确。

2.5 网关认证

2.5.1 技术方案

到目前为止,测试通过了认证服务颁发jwt令牌,客户端携带jwt访问资源服务,资源服务对jwt的合法性进行验证。如下图:


仔细观察此图,遗漏了本项目架构中非常重要的组件:网关,加上网关并完善后如下图所示:


所有访问微服务的请求都要经过网关,在网关进行用户身份的认证可以将很多非法的请求拦截到微服务以外,这叫做网关认证。

下边需要明确网关的职责:

1、网站白名单维护

针对不用认证的URL全部放行。

2、校验jwt的合法性。

除了白名单剩下的就是需要认证的请求,网关需要验证jwt的合法性,jwt合法则说明用户身份合法,否则说明身份不合法则拒绝继续访问。

网关负责授权吗?

网关不负责授权,对请求的授权操作在各个微服务进行,因为微服务最清楚用户有哪些权限访问哪些接口。

2.5.2 实现网关认证

下边实现网关认证,实现以下职责:

1、网站白名单维护

针对不用认证的URL全部放行。

2、校验jwt的合法性。

除了白名单剩下的就是需要认证的请求,网关需要验证jwt的合法性,jwt合法则说明用户身份合法,否则说明身份不合法则拒绝继续访问。

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包下。

3、配置白名单文件security-whitelist.properties

内容如下(持续补充)

/auth/**=认证地址
/content/open/**=内容管理公开访问接口
/media/open/**=媒资管理公开访问接口

重启网关工程,进行测试

1、申请令牌

2、通过网关访问资源服务

这里访问内容管理服务

### 通过网关访问资源服务
GET http://localhost:63010/content/course/2
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsicmVzMSJdLCJ1c2VyX25hbWUiOiJ6aGFuZ3NhbiIsInNjb3BlIjpbImFsbCJdLCJleHAiOjE2NjQzNjIzMTAsImF1dGhvcml0aWVzIjpbInAxIl0sImp0aSI6Ijc2OTkwMGNiLWM1ZjItNGRiNC1hZWJmLWY1MzgxZDQxZWMyZCIsImNsaWVudF9pZCI6ImMxIn0.lOITjUgYg2HCh5mDPK9EvJJqz-tIupKVfmP8yWJQIKs

当token正确时可以正常访问资源服务,token验证失败返回token无效:

{
  "errMessage": "认证令牌无效"
}

注意:网关鉴权功能调试通过后,由于目前还没有开发认证功能,前端请求网关的URL不在白名单中间时会"没有认证"的错误,暂时在白名单中添加
全部放行配置,待认证功能开发完成再屏蔽全部放行配置,

]
由于是在网关处进行令牌校验,所以在微服务处不再校验令牌的合法性,修改内容管理服务的ResouceServerConfig类,屏蔽authenticated()。

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

3 用户认证

3.1 需求分析

至此我们了解了使用Spring
Security进行认证授权的过程,本节实现用户认证功能。

目前各大网站的认证方式非常丰富:账号密码认证、手机验证码认证、扫码登录等。

本项目也要支持多种认证试。

3.2 连接用户中心数据库

3.2.1 连接数据库认证

基于的认证流程在研究Spring
Security过程中已经测试通过,到目前为止用户认证流程如下:


认证所需要的用户信息存储在用户中心数据库,现在需要将认证服务连接数据库查询用户信息。

在研究Spring Security的过程中是将用户信息硬编码,如下:

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

我们要认证服务中连接用户中心数据库查询用户信息。

如何使用Spring Security连接数据库认证吗?

前边学习Spring Security工作原理时有一张执行流程图,如下图:


用户提交账号和密码由DaoAuthenticationProvider调用UserDetailsService的loadUserByUsername()方法获取UserDetails用户信息。

查询DaoAuthenticationProvider的源代码如下:


UserDetailsService是一个接口,如下:

package org.springframework.security.core.userdetails;

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

UserDetails是用户信息接口

public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();

    String getPassword();

    String getUsername();

    boolean isAccountNonExpired();

    boolean isAccountNonLocked();

    boolean isCredentialsNonExpired();

    boolean isEnabled();
}

我们只要实现UserDetailsService 接口查询数据库得到用户信息返回UserDetails
类型的用户信息即可,框架调用loadUserByUsername()方法拿到用户信息之后是如何执行的,见下图:


首先屏蔽原来定义的UserDetailsService。

    //配置用户信息服务
//    @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;
//    }

下边自定义UserDetailsService

package com.xuecheng.ucenter.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.xuecheng.ucenter.mapper.XcUserMapper;
import com.xuecheng.ucenter.model.po.XcUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

/**
 * @author Mr.M
 * @version 1.0
 * @description TODO
 * @date 2022/9/28 18:09
 */
@Service
public class UserServiceImpl implements UserDetailsService {

    @Autowired
    XcUserMapper xcUserMapper;

    /**
     * @description 根据账号查询用户信息
     * @param s  账号
     * @return org.springframework.security.core.userdetails.UserDetails
     * @author Mr.M
     * @date 2022/9/28 18:30
    */
    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {

        XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, s));
        if(user==null){
            //返回空表示用户不存在
            return null;
        }
        //取出数据库存储的正确密码
        String password  =user.getPassword();
        //用户权限,如果不加报Cannot pass a null GrantedAuthority collection
        String[] authorities= {"test"};
        //创建UserDetails对象,权限信息待实现授权功能时再向UserDetail中加入
        UserDetails userDetails = User.withUsername(user.getUsername()).password(password).authorities(authorities).build();

        return userDetails;
    }


}

数据库中的密码加过密的,用户输入的密码是明文,我们需要修改密码格式器PasswordEncoder,原来使用的是NoOpPasswordEncoder,它是通过明文方式比较密码,现在我们修改为BCryptPasswordEncoder,它是将用户输入的密码编码为BCrypt格式与数据库中的密码进行比对。

如下:

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

我们通过测试代码测试BCryptPasswordEncoder,如下:

public static void main(String[] args) {
    String password = "111111";
    PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
    for(int i=0;i<10;i++) {
        //每个计算出的Hash值都不一样
        String hashPass = passwordEncoder.encode(password);
        System.out.println(hashPass);
        //虽然每次计算的密码Hash值不一样但是校验是通过的
        boolean f = passwordEncoder.matches(password, hashPass);
        System.out.println(f);
    }
}

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

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

 @Override
  public void configure(ClientDetailsServiceConfigurer clients)
          throws Exception {
        clients.inMemory()// 使用in-memory存储
                .withClient("XcWebApp")// client_id
//                .secret("secret")//客户端密钥
                .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://www.51xuecheng.cn")
   ;
  }

现在重启认证服务。

下边使用httpclient进行测试:

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

输入正确的账号和密码,申请令牌成功。

输入错误的密码,报错:

{
  "error": "invalid_grant",
  "error_description": "用户名或密码错误"
}

输入错误的账号,报错:

{
  "error": "unauthorized",
  "error_description": "UserDetailsService returned null, which is an interface contract violation"
}
3.2.2 扩展用户身份信息

用户表中存储了用户的账号、手机号、email,昵称、qq等信息,UserDetails接口只返回了username、密码等信息,如下:

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会调用UserDetailService查询用户的信息,这里是可以获取到齐全的用户信息的。由于JWT令牌中用户身份信息来源于UserDetails,UserDetails中仅定义了username为用户的身份信息,这里有两个思路:第一是可以扩展UserDetails,使之包括更多的自定义属性,第二也可以扩展username的内容
,比如存入json数据内容作为username的内容。相比较而言,方案二比较简单还不用破坏UserDetails的结构,我们采用方案二。

修改UserServiceImpl如下:

package com.xuecheng.ucenter.service.impl;

import com.alibaba.fastjson.json;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.xuecheng.ucenter.mapper.XcUserMapper;
import com.xuecheng.ucenter.model.po.XcUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

/**
 * @author Mr.M
 * @version 1.0
 * @description TODO
 * @date 2022/9/28 18:09
 */
@Service
public class UserServiceImpl implements UserDetailsService {

    @Autowired
    XcUserMapper xcUserMapper;

    /**
     * @description 根据账号查询用户信息
     * @param s  账号
     * @return org.springframework.security.core.userdetails.UserDetails
     * @author Mr.M
     * @date 2022/9/28 18:30
    */
    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {

        XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, s));
        if(user==null){
            //返回空表示用户不存在
            return null;
        }

        //取出数据库存储的正确密码
        String password  =user.getPassword();
        //用户权限,如果不加报Cannot pass a null GrantedAuthority collection
        String[] authorities = {"p1"};
       //为了安全在令牌中不放密码
        user.setPassword(null);
        //将user对象转json
        String userString = json.tojsonString(user);
        //创建UserDetails对象
        UserDetails userDetails = User.withUsername(userString).password(password).authorities(authorities).build();

        return userDetails;
    }


}

重启认证服务,重新生成令牌,生成成功。

我们可以使用check_token查询jwt的内容

###校验jwt令牌
POST {{auth_host}}/oauth/check_token?token=

响应示例如下,


{
  "aud": [
    "res1"
  ],
  "user_name": "{\"birthday\":\"2022-09-28T19:28:46\",\"createTime\":\"2022-09-28T08:32:03\",\"id\":\"50\",\"name\":\"学生1\",\"nickname\":\"大水牛\",\"password\":\"$2a$10$0pt7WlfTbnPDTcWtp/.2Mu5CTXvohnNQhR628qq4RoKSc0dGAdEgm\",\"sex\":\"1\",\"status\":\"1\",\"username\":\"stu1\",\"userpic\":\"http://file.51xuecheng.cn/dddf\",\"utype\":\"101001\"}",
  "scope": [
    "all"
  ],
  "active": true,
  "exp": 1664372184,
  "authorities": [
    "p1"
  ],
  "jti": "73da9f7b-bd8c-45ac-9add-46b711d11fb8",
  "client_id": "c1"
}

user_name存储了用户信息的json格式,在资源服务中就可以取出该json格式的内容转为用户对象去使用。

3.2.3 资源服务获取用户身份

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

在content-api中定义此类:

package com.xuecheng.content.util;

import com.alibaba.fastjson.json;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.context.SecurityContextHolder;

import java.io.Serializable;
import java.time.LocalDateTime;

/**
 * @author Mr.M
 * @version 1.0
 * @description 获取当前用户身份工具类
 * @date 2022/10/18 18:02
 */
@Slf4j
public class SecurityUtil {

    public static XcUser getUser() {
        try {
            Object principalObj = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
            if (principalObj instanceof String) {
                //取出用户身份信息
                String principal = principalObj.toString();
                //将json转成对象
                XcUser user = json.parseObject(principal, XcUser.class);
                return user;
            }
        } catch (Exception e) {
            log.error("获取当前登录用户身份出错:{}", e.getMessage());
            e.printStackTrace();
        }

        return null;
    }


    @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("courseId") Long courseId){
    //取出当前用户身份
//    Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
   SecurityUtil.XcUser user = SecurityUtil.getUser();
    System.out.println(user);

    return courseBaseInfoService.getCourseBaseInfo(courseId);
}

重启内容管理服务:

1、启动认证服务、网关、内容管理服务

2、生成新的令牌

3、携带令牌访问内容管理服务的查询课程接口

3.3 支持认证方式多样

3.3.1 统一认证入口

目前各大网站的认证方式非常丰富:账号密码认证、手机验证码认证、扫码登录等。基于当前研究的Spring
Security认证流程如何支持多样化的认证方式呢?

1、支持账号和密码认证

采用OAuth2协议的密码模式即可实现。

2、支持手机号加验证码认证

用户认证提交的是手机号和验证码,并不是账号和密码。

3、微信扫码认证

基于OAuth2协议与微信交互,学成在线网站向微信服务器申请到一个令牌,然后携带令牌去微信查询用户信息,查询成功则用户在学成在线项目认证通过。

目前我们测试通过OAuth2的密码模式,用户认证会提交账号和密码,由DaoAuthenticationProvider调用UserDetailsService的loadUserByUsername()方法获取UserDetails用户信息。

在前边我们自定义了UserDetailsService接口实现类,通过loadUserByUsername()方法根据账号查询用户信息。

而不同的认证方式提交的数据不一样,比如:手机加验证码方式会提交手机号和验证码,账号密码方式会提交账号、密码、验证码。

我们可以在loadUserByUsername()方法上作文章,将用户原来提交的账号数据改为提交json数据,json数据可以扩展不同认证方式所提交的各种参数。

首先创建一个DTO类表示认证的参数:

package com.xuecheng.ucenter.model.dto;

import lombok.Data;

import java.util.HashMap;
import java.util.Map;

/**
 * @author Mr.M
 * @version 1.0
 * @description 认证用户请求参数
 * @date 2022/9/29 10:56
 */
@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()方法可以修改如下:

package com.xuecheng.ucenter.service.impl;

import com.alibaba.fastjson.json;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.xuecheng.ucenter.mapper.XcUserMapper;
import com.xuecheng.ucenter.model.dto.AuthParamsDto;
import com.xuecheng.ucenter.model.po.XcUser;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

/**
 * @author Mr.M
 * @version 1.0
 * @description 自定义UserDetailsService用来对接Spring Security
 * @date 2022/9/28 18:09
 */
@Slf4j
@Service
public class UserServiceImpl implements UserDetailsService {

    @Autowired
    XcUserMapper xcUserMapper;

    /**
     * @description 查询用户信息组成用户身份信息
     * @param s  AuthParamsDto类型的json数据
     * @return org.springframework.security.core.userdetails.UserDetails
     * @author Mr.M
     * @date 2022/9/28 18:30
    */
    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {

        AuthParamsDto authParamsDto = null;
        try {
            //将认证参数转为AuthParamsDto类型
            authParamsDto = json.parseObject(s, AuthParamsDto.class);
        } catch (Exception e) {
            log.info("认证请求不符合项目要求:{}",s);
            throw new RuntimeException("认证请求数据格式不对");
        }
        //账号
        String username = authParamsDto.getUsername();
        XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, username));
        if(user==null){
            //返回空表示用户不存在
            return null;
        }
        //取出数据库存储的正确密码
        String password  =user.getPassword();
        //用户权限,如果不加报Cannot pass a null GrantedAuthority collection
        String[] authorities = {"p1"};
        //将user对象转json
        String userString = json.tojsonString(user);
        //创建UserDetails对象
        UserDetails userDetails = User.withUsername(userString).password(password).authorities(authorities).build();

        return userDetails;
    }

}

原来的DaoAuthenticationProvider
会进行密码校验,现在重新定义DaoAuthenticationProviderCustom类,重写类的additionalAuthenticationChecks方法。

package com.xuecheng.auth.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;

/**
 * @description 自定义DaoAuthenticationProvider
 * @author Mr.M
 * @date 2022/9/29 10:31
 * @version 1.0
 */
@Slf4j
@Component
public class DaoAuthenticationProviderCustom extends DaoAuthenticationProvider {


 @Autowired
 public void setUserDetailsService(UserDetailsService userDetailsService) {
  super.setUserDetailsService(userDetailsService);
 }


 //屏蔽密码对比
 protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {


 }

}

修改WebSecurityConfig类指定daoAuthenticationProviderCustom

@Autowired
DaoAuthenticationProviderCustom daoAuthenticationProviderCustom;


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

此时可以重启认证服务,测试申请令牌接口,传入的账号信息改为json数据,如下:

################扩展认证请求参数后######################
###密码模式
POST {{auth_host}}/auth/oauth/token?client_id=XcWebApp&client_secret=XcWebApp&grant_type=password&username={"username":"stu1","authType":"password","password":"111111"}

经过测试发现loadUserByUsername()方法可以正常接收到认证请求中的json数据。

有了这些认证参数我们可以定义一个认证Service接口去进行各种方式的认证。

定义用户信息,为了扩展性让它继承XcUser

@Data
public class XcUserExt extends XcUser {
}

定义认证Service 接口

package com.xuecheng.ucenter.service;

import com.xuecheng.ucenter.model.dto.AuthParamsDto;
import com.xuecheng.ucenter.model.po.XcUser;

/**
 * @description 认证service
 * @author Mr.M
 * @date 2022/9/29 12:10
 * @version 1.0
 */
public interface AuthService {

   /**
    * @description 认证方法
    * @param authParamsDto 认证参数
    * @return com.xuecheng.ucenter.model.po.XcUser 用户信息
    * @author Mr.M
    * @date 2022/9/29 12:11
   */
   XcUserExt execute(AuthParamsDto authParamsDto);

}

loadUserByUsername()修改如下:

@Slf4j
@Service
public class UserServiceImpl implements UserDetailsService {

    @Autowired
    XcUserMapper xcUserMapper;

    @Autowired
    ApplicationContext applicationContext;


    /**
     * @description 查询用户信息组成用户身份信息
     * @param s  AuthParamsDto类型的json数据
     * @return org.springframework.security.core.userdetails.UserDetails
     * @author Mr.M
     * @date 2022/9/28 18:30
    */
    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {

        AuthParamsDto authParamsDto = null;
        try {
            //将认证参数转为AuthParamsDto类型
            authParamsDto = json.parseObject(s, AuthParamsDto.class);
        } catch (Exception e) {
            log.info("认证请求不符合项目要求:{}",s);
            throw new RuntimeException("认证请求数据格式不对");
        }
        //开始认证
        authService.execute(authParamsDto);
        .....

到此我们基于Spring Security认证流程修改为如下:

3.3.2 实现账号密码认证

上节定义了AuthService认证接口,下边实现该接口实现账号密码认证

package com.xuecheng.ucenter.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.xuecheng.ucenter.mapper.XcUserMapper;
import com.xuecheng.ucenter.model.dto.AuthParamsDto;
import com.xuecheng.ucenter.model.po.XcUser;
import com.xuecheng.ucenter.service.AuthService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

/**
 * @description 账号密码认证
 * @author Mr.M
 * @date 2022/9/29 12:12
 * @version 1.0
 */
 @Service("password_authservice")
public class PasswordAuthServiceImpl implements AuthService {

 @Autowired
 XcUserMapper xcUserMapper;

 @Autowired
 PasswordEncoder passwordEncoder;


 @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);
  //校验密码
  //取出数据库存储的正确密码
  String passwordDb  =user.getPassword();
  String passwordForm = authParamsDto.getPassword();
  boolean matches = passwordEncoder.matches(passwordForm, passwordDb);
  if(!matches){
   throw new RuntimeException("账号或密码错误");
  }
  return xcUserExt;
 }
}

修改UserServiceImpl类,根据认证方式使用不同的认证bean

package com.xuecheng.ucenter.service.impl;

import com.alibaba.fastjson.json;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.xuecheng.ucenter.mapper.XcUserMapper;
import com.xuecheng.ucenter.model.dto.AuthParamsDto;
import com.xuecheng.ucenter.model.po.XcUser;
import com.xuecheng.ucenter.service.AuthService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

/**
 * @author Mr.M
 * @version 1.0
 * @description 自定义UserDetailsService用来对接Spring Security
 * @date 2022/9/28 18:09
 */
@Slf4j
@Service
public class UserServiceImpl implements UserDetailsService {

    @Autowired
    XcUserMapper xcUserMapper;

    @Autowired
    ApplicationContext applicationContext;

//    @Autowired
//    AuthService authService;

    /**
     * @description 查询用户信息组成用户身份信息
     * @param s  AuthParamsDto类型的json数据
     * @return org.springframework.security.core.userdetails.UserDetails
     * @author Mr.M
     * @date 2022/9/28 18:30
    */
    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {

        AuthParamsDto authParamsDto = null;
        try {
            //将认证参数转为AuthParamsDto类型
            authParamsDto = json.parseObject(s, AuthParamsDto.class);
        } catch (Exception e) {
            log.info("认证请求不符合项目要求:{}",s);
            throw new RuntimeException("认证请求数据格式不对");
        }

        //认证方法
        String authType = authParamsDto.getAuthType();
        AuthService authService =  applicationContext.getBean(authType + "_authservice",AuthService.class);
        XcUserExt user = authService.execute(authParamsDto);

        return getUserPrincipal(user);
    }


    /**
     * @description 查询用户信息
     * @param user  用户id,主键
     * @return com.xuecheng.ucenter.model.po.XcUser 用户信息
     * @author Mr.M
     * @date 2022/9/29 12:19
    */
    public UserDetails getUserPrincipal(XcUserExt user){
        //用户权限,如果不加报Cannot pass a null GrantedAuthority collection
        String[] authorities = {"p1"};
        String password = user.getPassword();
        //为了安全在令牌中不放密码
        user.setPassword(null);
        //将user对象转json
        String userString = json.tojsonString(user);
        //创建UserDetails对象
        UserDetails userDetails = User.withUsername(userString).password(password ).authorities(authorities).build();
        return userDetails;
    }

}

重启认证服务,测试申请令牌接口。

1、测试账号和密码都正确的情况是否可以申请令牌成功。

2、测试密码错误的情况。

3、测试账号不存在情况。

3.4 验证码服务

3.4.1 部署验证码服务工程

在认证时一般都需要输入验证码,验证码有什么用?

验证码可以防止恶性攻击,比如:XSS跨站脚本攻击、CSRF跨站请求伪造攻击,一些比较复杂的图形验证码可以有效的防止恶性攻击。

为了保护系统的安全在一些比较重要的操作都需要验证码。


验证码的类型也有很多:图片、语音、手机短信验证码等。

本项目创建单独的验证码服务为各业务提供验证码的生成、校验等服务。

拷贝课程资料目录xuecheng-plus-checkcode验证码服务工程到自己的工程目录。


定义nacos配置文件

server:
  servlet:
    context-path: /checkcode
  port: 63075

注意修改bootstrap.yml中的命名空间为自己定义的命名空间。

配置redis-dev.yaml,保存redis服务启动


内容如下:

spring: 
  redis:
    host: 192.168.101.65
    port: 6379
    password: redis
    database: 0
    lettuce:
      pool:
        max-active: 20
        max-idle: 10
        min-idle: 0
    timeout: 10000
    #redisson:
      #配置文件目录
      #config: classpath:singleServerConfig.yaml
3.4.2 验证码接口测试

验证码服务对外提供的接口有:

1、生成验证码

2、校验验证码。

如下:


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

拿图片验证码举例:

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

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

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

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

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

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


根据接口分析,验证码服务接口如下:

package com.xuecheng.checkcode.controller;

import com.xuecheng.base.model.RestResponse;
import com.xuecheng.checkcode.model.CheckCodeParamsDto;
import com.xuecheng.checkcode.model.CheckCodeResultDto;
import com.xuecheng.checkcode.service.CheckCodeService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiImplicitParams;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import java.util.Map;

/**
 * @author Mr.M
 * @version 1.0
 * @description 验证码服务接口
 * @date 2022/9/29 18:39
 */
@Api(value = "验证码服务接口")
@RestController
public class CheckCodeController {


    @ApiOperation(value="生成验证信息", notes="生成验证信息")
    @PostMapping(value = "/pic")
    public CheckCodeResultDto generatePicCheckCode(CheckCodeParamsDto checkCodeParamsDto){
        
    }

    @ApiOperation(value="校验", notes="校验")
    @ApiImplicitParams({
            @ApiImplicitParam(name = "name", value = "业务名称", required = true, dataType = "String", paramType="query"),
            @ApiImplicitParam(name = "key", value = "验证key", required = true, dataType = "String", paramType="query"),
            @ApiImplicitParam(name = "code", value = "验证码", required = true, dataType = "String", paramType="query")
    })
    @PostMapping(value = "/verify")
    public Boolean verify(String key, String code){
        
    }
}

1、生成验证码接口

### 申请验证码
POST {{checkcode_host}}/checkcode/pic

2、校验验证码接口

根据生成验证码返回的key以及日志中输出正确的验证码去测试。

### 校验验证码
POST {{checkcode_host}}/checkcode/verify?key=checkcode4506b95bddbe46cdb0d56810b747db1b&code=70dl

3.5 账号密码认证

3.5.1 需求分析

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


执行流程如下:

3.5.2 账号密码认证开发

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

package com.xuecheng.ucenter.feignclient;

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

/**
 * @description 
 * @author Mr.M
 * @date 2022/9/20 20:29
 * @version 1.0
 */
 @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);

}

CheckCodeClientFactory:

@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(basePackages={"com.xuecheng.*.feignclient"})

配置文件引入feign-dev.yaml

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

2、完善PasswordAuthServiceImpl

package com.xuecheng.ucenter.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.xuecheng.ucenter.feignclient.CheckCodeClient;
import com.xuecheng.ucenter.mapper.XcUserMapper;
import com.xuecheng.ucenter.model.dto.AuthParamsDto;
import com.xuecheng.ucenter.model.po.XcUser;
import com.xuecheng.ucenter.service.AuthService;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

/**
 * @description 账号密码认证
 * @author Mr.M
 * @date 2022/9/29 12:12
 * @version 1.0
 */
 @Service("password_authservice")
public class PasswordAuthServiceImpl implements AuthService {

 @Autowired
 XcUserMapper xcUserMapper;

 @Autowired
 PasswordEncoder passwordEncoder;
 @Autowired
 CheckCodeClient checkCodeClient;

 @Override
 public XcUser execute(AuthParamsDto authParamsDto) {

  //校验验证码
  String checkcode = authParamsDto.getCheckcode();
  String checkcodekey = authParamsDto.getCheckcodekey();

  if(StringUtils.isBlank(checkcodekey) || StringUtils.isBlank(checkcode)){
   throw new RuntimeException("验证码为空");

  }
  Boolean verify = checkCodeClient.verify(checkcodekey, checkcode);
  if(!verify){
   throw new RuntimeException("验证码输入错误");
  }
  //账号
  String username = authParamsDto.getUsername();
  XcUser user = xcUserMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getUsername, username));
  if(user==null){
   //返回空表示用户不存在
   throw new RuntimeException("账号不存在");
  }
  //校验密码
  //取出数据库存储的正确密码
  String passwordDb  =user.getPassword();
  String passwordForm = authParamsDto.getPassword();
  boolean matches = passwordEncoder.matches(passwordForm, passwordDb);
  if(!matches){
   throw new RuntimeException("账号或密码错误");
  }
  return user;
 }
}

小技巧:目前账号密码方式添加了验证码校验,为了后期获取令牌方便可以重新定义一个不需要验证码校验的认证类AuthService
,AuthService 中去掉验证码的校验,方便生成令牌。

3.5.3 账号密码认证测试

1、使用浏览器访问 http://www.51xuecheng.cn/sign.html


2、首先测试验证码,分别输入正确的验证码和错误的验证码进行测试

3、输入正确的账号密码和错误的账号密码进行测试

登录成功将jwt令牌存储cookie.

4、测试自动登录

勾选自动登录cookie生成时间为30天,不勾选自动登录关闭浏览器窗口后自动删除cookie。

4 微信扫码登录

4.1 接入规范

4.1.1 接入流程

微信扫码登录基于OAuth2协议的授权码模式,

接口文档:

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

流程如下:


第三方应用获取access_token令牌后即可请求微信获取用户的信息,成功获取到用户的信息表示用户在第三方应用认证成功。

4.1.2 请求获取授权码

第三方使用网站应用授权登录前请注意已获取相应网页授权作用域(scope=snsapi_login),则可以通过在
PC 端打开以下链接:
https://open.weixin.qq.com/connect/qrconnect?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=SCOPE&state=STATE#wechat_redirect
若提示"该链接无法访问",请检查参数是否填写错误,如redirect_uri的域名与审核时填写的授权域名不一致或
scope 不为snsapi_login。

参数说明


返回说明

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

redirect_uri?code=CODE&state=STATE

若用户禁止授权,则不会发生重定向。

登录一号店网站应用 https://test.yhd.com/wechat/login.do
打开后,一号店会生成 state 参数,跳转到
https://open.weixin.qq.com/connect/qrconnect?appid=wxbdc5610cc59c1631&redirect_uri=https%3A%2F%2Fpassport.yhd.com%2Fwechat%2Fcallback.do&response_type=code&scope=snsapi_login&state=3d6be0a4035d839573b04816624a415e#wechat_redirect
微信用户使用微信扫描二维码并且确认登录后,PC端会跳转到
https://test.yhd.com/wechat/callback.do?code=CODE&state=3d6be0a40sssssxxxxx6624a415e
为了满足网站更定制化的需求,我们还提供了第二种获取 code
的方式,支持网站将微信登录二维码内嵌到自己页面中,用户使用微信扫码授权后通过
JS 将code返回给网站。
JS微信登录主要用途:网站希望用户在网站内就能完成登录,无需跳转到微信域下登录后再返回,提升微信登录的流畅性与成功率。
网站内嵌二维码微信登录 JS 实现办法:

步骤1:在页面中先引入如下 JS 文件(支持https):

http://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js

步骤2:在需要使用微信登录的地方实例以下 JS 对象:

 var obj = new WxLogin({
 self_redirect:true,
 id:"login_container", 
 appid: "", 
 scope: "", 
 redirect_uri: "",
  state: "",
 style: "",
 href: ""
 });


4.1.3 通过 code 获取access_token

通过 code 获取access_token

https://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code


返回说明

正确的返回:

{ 
"access_token":"ACCESS_TOKEN", 
"expires_in":7200, 
"refresh_token":"REFRESH_TOKEN",
"openid":"OPENID", 
"scope":"SCOPE",
"unionid": "o6_bmasdasdsad6_2sgVt7hMZOPfL"
}

参数说明


错误返回样例:

{"errcode":40029,"errmsg":"invalid code"}
4.1.4 通过access_token调用接口

获取access_token后,进行接口调用,有以下前提:

access_token有效且未超时;
微信用户已授权给第三方应用帐号相应接口作用域(scope)。

对于接口作用域(scope),能调用的接口有以下:


其中snsapi_base属于基础接口,若应用已拥有其它 scope
权限,则默认拥有snsapi_base的权限。使用snsapi_base可以让移动端网页授权绕过跳转授权登录页请求用户授权的动作,直接跳转第三方网页带上授权临时票据(code),但会使得用户已授权作用域(scope)仅为snsapi_base,从而导致无法获取到需要用户授权才允许获得的数据和基础功能。
接口调用方法可查阅《微信授权关系接口调用指南》

获取用户信息接口文档:https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Authorized_Interface_Calling_UnionID.html

接口地址

http请求方式: GET
https://api.weixin.qq.com/sns/userinfo?access_token=ACCESS_TOKEN&openid=OPENID

如下:


响应:

{
"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        用户头像,最后一个数值代表正方形头像大小(有0466496132数值可选,0代表640*640正方形头像),用户没有头像时该项为空
privilege        用户特权信息,json数组,如微信沃卡用户为(chinaunicom)
unionid          用户统一标识。针对一个微信开放平台帐号下的应用,同一用户的 unionid 是唯一的。

4.2 准备开发环境

4.2.1 添加应用

1、注册微信开放平台

https://open.weixin.qq.com/

2、添加应用

进入网站应用,添加应用


3、添加应用需要指定一个外网域名作为微信回调域名

审核通过后,生成app密钥。

最终获取appID和AppSecret

4.2.2 内网穿透

我们的开发环境在局域网,微信回调指向一个公网域名。

如何让微信回调请求至我们的开发计算机上呢?

可以使用内网穿透技术,什么是内网穿透?

内网穿透简单来说就是将内网外网通过隧道打通,让内网的数据让外网可以获取。比如常用的办公室软件等,一般在办公室或家里,通过拨号上网,这样办公软件只有在本地的局域网之内才能访问,那么问题来了,如果是手机上,或者公司外地的办公人员,如何访问到办公软件呢?这就需要内网穿透工具了。开启隧道之后,网穿透工具会分配一个专属域名/端口,办公软件就已经在公网上了,在外地的办公人员可以在任何地方愉快的访问办公软件了~~


1、在内网穿透服务器上开通隧道,配置外网域名,配置穿透内网的端口即本地电脑上的端口。


这里我们配置认证服务端口,最终实现通过外网域名访问本地认证服务。

2、在本地电脑上安装内网穿透的工具,工具上配置内网穿透服务器隧道token。

市面上做内网穿透的商家很多,需要时可以查阅资料了解下。

4.3 接入微信登录

4.3.1 接入分析

根据OAuth2协议授权码流程,结合本项目自身特点,分析接入微信扫码登录的流程如下:

]
本项目认证服务需要做哪些事?

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

2、收到授权码调用微信接口申请令牌。

3、申请到令牌调用微信获取用户信息

4、获取用户信息成功将其写入本项目用户中心数据库。

5、最后重定向到浏览器自动登录。

4.3.2 定义接口

参考接口规范中"请求获取授权码" 定义接收微信下发的授权码接口,

定义WxLoginController类,如下:

@Slf4j
@Controller
public class WxLoginController {

    @RequestMapping("/wxLogin")
    public String wxLogin(String code, String state) throws IOException {
        log.debug("微信扫码回调,code:{},state:{}",code,state);
        //请求微信申请令牌,拿到令牌查询用户信息,将用户信息写入本项目数据库
        XcUser xcUser = new XcUser();
        //暂时硬编写,目的是调试环境
        xcUser.setUsername("t1");
        if(xcUser==null){
            return "redirect:http://www.51xuecheng.cn/error.html";
        }
        String username = xcUser.getUsername();
        return "redirect:http://www.51xuecheng.cn/sign.html?username="+username+"&authType=wx";
    }
}

定义微信认证的service

package com.xuecheng.ucenter.service.impl;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;

/**
 * @author Mr.M
 * @version 1.0
 * @description 微信扫码认证
 * @date 2022/9/29 12:12
 */
@Slf4j
@Service("wx_authservice")
public class WxAuthServiceImpl implements AuthService {

    @Autowired
    XcUserMapper xcUserMapper;

    @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.3.3 接口环境测试

接口定义好下边进行测试下,主要目的是测试接口调度的环境。

1、启动内网穿透工具

2、在/wxLogin接口中打断点

3、打开前端微信扫码页面


点击微信图标打开二维码


用户扫码,确认授权

此时正常进入 /wxLogin
方法,最后跳转到http://www.51xuecheng.cn/sign.html?username=t1&authType=wx。

4.3.4 接入微信认证

接下来请求微信申请令牌。

1、使用restTemplate请求微信,配置RestTemplate bean

在启动类配置restTemplate

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

2、定义与微信认证的service接口:

/**
 * @author Mr.M
 * @version 1.0
 * @description 微信认证接口
 * @date 2023/2/21 22:15
 */
public interface WxAuthService {

    public XcUser wxAuth(String code);

}

3、下边在controller中调用wxAuth接口:

@Slf4j
@Controller
public class WxLoginController {

    @Autowired
    WxAuthService 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://www.51xuecheng.cn/error.html";
        }
        String username = xcUser.getUsername();
        return "redirect:http://www.51xuecheng.cn/sign.html?username="+username+"&authType=wx";
    }
}

4、在WxAuthService 的wxAuth方法中实现申请令牌、查询用户信息等内容。


@Slf4j
@Service("wx_authservice")
public class WxAuthServiceImpl implements AuthService, WxAuthService {
@Autowired
XcUserMapper xcUserMapper;
@Autowired
RestTemplate restTemplate;

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

public XcUser wxAuth(String code){

    //收到code调用微信接口申请access_token
    Map<String, String> access_token_map = getAccess_token(code);
    if(access_token_map==null){
        return null;
    }
    System.out.println(access_token_map);
    String openid = access_token_map.get("openid");
    String access_token = access_token_map.get("access_token");
    //拿access_token查询用户信息
    Map<String, String> userinfo = getUserinfo(access_token, openid);
    if(userinfo==null){
        return null;
    }
    //添加用户到数据库
    XcUser xcUser = null;
    
    return xcUser;
}

/**
 * 申请访问令牌,响应示例
 {
 "access_token":"ACCESS_TOKEN",
 "expires_in":7200,
 "refresh_token":"REFRESH_TOKEN",
 "openid":"OPENID",
 "scope":"SCOPE",
 "unionid": "o6_bmasdasdsad6_2sgVt7hMZOPfL"
 }
*/
private Map<String,String> getAccess_token(String code) {

    String wxUrl_template = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code";
    //请求微信地址
    String wxUrl = String.format(wxUrl_template, appid, secret, code);

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

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

    String result = exchange.getBody();
    log.info("调用微信接口申请access_token: 返回值:{}", result);
    Map<String,String> resultMap = json.parseObject(result, Map.class);

    return resultMap;
}

/**获取用户信息,示例如下:
 {
 "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"
 }
*/
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;
}
....

测试获取用户信息

1、在获取用户信息处打断点

2、进入http://www.51xuecheng.cn/wxsign.html

3、手机扫码授权

4.3.5 保存用户信息

向数据库保存用户信息,如果用户不存在将其保存在数据库。

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

调用保存用户信息


@Autowired
WxAuthServiceImpl currentProxy;

public XcUser wxAuth(String code){

    //收到code调用微信接口申请access_token
    Map<String, String> access_token_map = getAccess_token(code);
    if(access_token_map==null){
        return null;
    }
    System.out.println(access_token_map);
    String openid = access_token_map.get("openid");
    String access_token = access_token_map.get("access_token");
    //拿access_token查询用户信息
    Map<String, String> userinfo = getUserinfo(access_token, openid);
    if(userinfo==null){
        return null;
    }
    //将用户信息保存到数据库
    XcUser xcUser = currentProxy.addWxUser(userinfo);
    return xcUser;
}

测试保存用户信息

1、在保存用户信息处打断点

2、进入http://www.51xuecheng.cn/wxsign.html

3、手机扫码授权

4、自动跳转到登录页面,提交认证成功。

5 用户授权

5.1 RBAC

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

RBAC分为两种方式:

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

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

角色的访问控制(Role-Based Access
Control)是按角色进行授权,比如:主体的角色为总经理可以查询企业运营报表,查询员工工资信息等,访问控制流程如下:


根据上图中的判断逻辑,授权代码可表示如下:

if(主体.hasRole("总经理角色id")){
查询工资
}

如果上图中查询工资所需要的角色变化为总经理和部门经理,此时就需要修改判断逻辑为"判断用户的角色是否是总经理或部门经理",修改代码如下:

if(主体.hasRole("总经理角色id") ||  主体.hasRole("部门经理角色id")){
    查询工资
}

根据上边的例子发现,当需要修改角色的权限时就需要修改授权的相关代码,系统可扩展性差。

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

Control)是按资源(或权限)进行授权,比如:用户必须具有查询工资权限才可以查询员工工资信息等,访问控制流程如下:


根据上图中的判断,授权代码可以表示为:

if(主体.hasPermission("查询工资权限标识")){
    查询工资
}

优点:系统设计时定义好查询工资的权限标识,即使查询工资所需要的角色变化为总经理和部门经理也不需要修改授权代码,系统可扩展性强。

5.2 资源服务授权流程

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

1、首先配置nginx代理

   http {
    server_names_hash_bucket_size 64;
    ...
   
   #前端开发服务
  upstream uidevserver{
    server 127.0.0.1:8601 weight=10;
  } 
   server {
        listen       80;
        server_name  teacher.51xuecheng.cn;
        #charset koi8-r;
        ssi on;
        ssi_silent_errors on;
        #access_log  logs/host.access.log  main;
        #location / {
         #   alias   D:/itcast2022/xc_edu3.0/code_1/dist/;
         #   index  index.html index.htm;
        #}
        location / {
            proxy_pass   http://uidevserver;
        }

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

加载nginx 配置。

2、在资源服务集成Spring Security

在需要授权的接口处使用@PreAuthorize(“hasAuthority(‘权限标识符’)”)进行控制

下边代码指定/course/list接口需要拥有xc_teachmanager_course_list 权限。


设置了@PreAuthorize表示执行此方法需要授权,如果当前用户请求接口没有权限则抛出异常

org.springframework.security.access.AccessDeniedException: 不允许访问

3、在统一异常处理处解析此异常信息

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


}

4、重启资源服务进行测试

使用教学机构用户登录系统

这里使用t1用户登录,账号:t1、密码:111111

登录成功,点击"教学机构"


当用户没有权限时页面提示:没有操作此功能的权限。

5.3 授权相关的数据模型

如何给用户分配权限呢?

首先要学习数据模型,本项目授权相关的数据表如下:


说明如下:

xc_user:用户表,存储了系统用户信息,用户类型包括:学生、老师、管理员等

xc_role:角色表,存储了系统的角色信息,学生、老师、教学管理员、系统管理员等。

xc_user_role:用户角色表,一个用户可拥有多个角色,一个角色可被多个用户所拥有

xc_menu:模块表,记录了菜单及菜单下的权限

xc_permission:角色权限表,一个角色可拥有多个权限,一个权限可被多个角色所拥有

本项目教学阶段不再实现权限定义及用户权限分配的功能,权限分配的界面原型如下图所示:


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

1、查询用户所拥有的权限

步骤:

查询用户的id

查询用户所拥有的角色

查询用户所拥有的权限

例子:

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 = ‘49’
)
)

2、给用户分配权限

1)添加权限

查询用户的id

查询权限的id

查询用户的角色,如果没有角色需要先给用户指定角色

向角色权限表添加记录

2)删除用户权限

本项目是基于角色分配权限,如果要删除用户的权限可以给用户换角色,那么新角色下的权限就是用户的权限;如果不换用户的角色可以删除角色下的权限即删除角色权限关系表相应记录,这样操作是将角色下的权限删除,属于该角色的用户都将删除此权限。

5.4 查询用户权限

使用Spring Security进行授权,首先在生成jwt前会查询用户的权限,如下图:


接下来需要修改UserServiceImpl和PasswordAuthServiceImpl从数据库查询用户的权限。

1、定义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);
}

2、修改PasswordAuthServiceImpl

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

//查询用户身份
public UserDetails getUserPrincipal(XcUserExt user){
    String password = user.getPassword();
    //查询用户权限
    List<XcMenu> xcMenus = menuMapper.selectPermissionByUserId(user.getId());
    List<String> permissions = new ArrayList<>();
    if(xcMenus.size()<=0){
        //用户权限,如果不加则报Cannot pass a null GrantedAuthority collection
        permissions.add("p1");
    }else{
        xcMenus.forEach(menu->{
            permissions.add(menu.getCode());
        });
    }
    //将用户权限放在XcUserExt中
    user.setPermissions(permissions);

    //为了安全在令牌中不放密码
    user.setPassword(null);
    //将user对象转json
    String userString = json.tojsonString(user);
    String[] authorities = permissions.toArray(new String[0]);
    UserDetails userDetails = User.withUsername(userString).password(password).authorities(authorities).build();
    return userDetails;

}

5.5 授权测试

以上实现了认证时从数据库查询用户的权限,下边进行用户授权测试。

重启认证服务,使用内容管理课程列表查询为例,代码如下:


用户拥有xc_teachmanager_course_list权限方可访问课程查询接口。

以用户stu1为例,当它没有此权限时页面报"没有此操作的权限"错误


将xc_teachmanager_course_list权限分配给用户。

1)首先找到当前用户的角色

2)找到xc_teachmanager_course_list权限的主键

3)在角色权限关系表中添加记录

分配完权限需要重新登录

由于用户分配了xc_teachmanager_course_list权限,用户具有访问课程查询接口的权限。

5.6 细粒度授权

5.6.1 什么是细粒度授权

什么是细粒度授权?

细粒度授权也叫数据范围授权,即不同的用户所拥有的操作权限相同,但是能够操作的数据范围是不一样的。一个例子:用户A和用户B都是教学机构,他们都拥有"我的课程"权限,但是两个用户所查询到的数据是不一样的。

本项目有哪些细粒度授权?

比如:

我的课程,教学机构只允许查询本教学机构下的课程信息。

我的选课,学生只允许查询自己所选课。

如何实现细粒度授权?

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

5.6.2 教学机构细粒度授权

教学机构在维护课程时只允许维护本机构的课程,教学机构细粒度授权过程如下:

1)获取当前登录的用户身份

2)得到用户所属教育机构的Id

3)查询该教学机构下的课程信息

最终实现了用户只允许查询自己机构的课程信息。

根据公司Id查询课程,流程如下:

1)教学机构用户登录系统,从用户身份中取出所属机构的id

在用户表中设计了company_id字段存储该用户所属的机构id.

2)接口层取出当前登录用户的身份,取出机构id

3) 将机构id传入service方法。

4) service方法将机构id传入Dao方法,最终查询出本机构的课程信息。

代码实现如下:

@ApiOperation("课程查询接口")
@PreAuthorize("hasAuthority('xc_teachmanager_course_list')")//拥有课程列表查询的权限方可访问
@PostMapping("/course/list")
public PageResult<CourseBase> list(PageParams pageParams, @RequestBody QueryCourseParamsDto queryCourseParams){
    //取出用户身份
    XcUser user = SecurityUtil.getUser();
    //机构id
    String companyId = user.getCompanyId();
    return courseBaseInfoService.queryCourseBaseList(Long.parseLong(companyId),pageParams,queryCourseParams);
}

Service方法如下:

@Override
public PageResult<CourseBase> queryCourseBaseList(Long companyId,PageParams pageParams, QueryCourseParamsDto queryCourseParamsDto) {

 //构建查询条件对象
 LambdaQueryWrapper<CourseBase> queryWrapper = new LambdaQueryWrapper<>();
 //机构id
 queryWrapper.eq(CourseBase::getCompanyId,companyId);
 ....
5.6.3 教学机构细粒度授权测试

使用一个教学机构的用户登录项目,并且此用户具有查询课程的权限。

手机修改数据库指定用户归属到一个机构中,涉及以下数据表:

xc_company为机构表

xc_company_user为机构用户关系表

xc_user表中有company_id字段。

我们准备了t1 用户作为此次测试的用户,使用此用户登录系统:

提前在查询课程列表接口处打上断点。

经过测试可以正常取出用户所属的机构id


跟踪持久层日志发现已将机构id传入dao方法,拼装sql语句,查询本机构的课程信息

6 实战

6.1 找回密码(实战)

需求:忘记密码需要找回,可以通过手机号找回密码,通过邮箱找回密码以及人工通道。

界面访问地址:http://www.51xuecheng.cn/findpassword.html


接口:

手机验证码:/api/checkcode/phone?param1=手机号

邮箱验证码:/api/checkcode/phone?param1=电子邮箱地址

找回密码:/api/auth/findpassword

请求:

{
     cellphone:'',
     email:'',
     checkcodekey:'',
     checkcode:'',
     confirmpwd:'',
     password:''
 }

响应:

200: 找回成功

其它:找回失败,失败原因使用统一异常处理返回的信息格式

执行流程
1、校验验证码,不一致则抛出异常

2、判断两次密码是否一致,不一致则抛出异常

3、根据手机号和邮箱查询用户

4、如果找到用户更新为新密码

实现:
controller层:

    @PostMapping("/findpassword")
    public void findPassword(@RequestBody PasswordParamDto findPswDto){
        userService.findPassword(findPswDto);
    }

service层:

@Service
public class UserServiceImpl implements UserService {

    @Autowired
    XcUserMapper userMapper;

    @Autowired
    XcUserRoleMapper xcUserRoleMapper;

    @Autowired
    StringRedisTemplate redisTemplate;

    @Autowired
    PasswordEncoder passwordEncoder;

    @Override
    public void findPassword(PasswordParamDto findPswDto) {
        String cellphone = findPswDto.getCellphone();
        String email = findPswDto.getEmail();
        if (StringUtils.isEmpty(cellphone) && StringUtils.isEmpty(email)) {
            throw new BadCredentialsException("手机号与邮箱不能为空");
        }

        String password = findPswDto.getPassword();
        String confirmpwd = findPswDto.getConfirmpwd();
        if (StringUtils.isEmpty(password) || StringUtils.isEmpty(confirmpwd)) {
            throw new BadCredentialsException("密码不能为空");
        }
        if (!password.equals(confirmpwd)) {
            throw new BadCredentialsException("两次密码不一致");
        }
        String checkcode = findPswDto.getCheckcode();
        if (StringUtils.isEmpty(checkcode)) {
            throw new BadCredentialsException("验证码不能为空");
        }
        String code;
        if (!StringUtils.isEmpty(cellphone)) {
            code = redisTemplate.opsForValue().get(cellphone);
        } else {
            code = redisTemplate.opsForValue().get(email);
        }
        if (StringUtils.isEmpty(code)) {
            throw new BadCredentialsException("验证码已经失效");
        }
        if (!code.equals(checkcode)) {
            throw new BadCredentialsException("验证码不正确");
        }
        XcUser xcUser = null;
        if (StringUtils.isEmpty(cellphone)) {
            xcUser = userMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getCellphone, cellphone));
        } else {
            xcUser = userMapper.selectOne(new LambdaQueryWrapper<XcUser>().eq(XcUser::getEmail, email));
        }
        if (xcUser == null) {
            throw new BadCredentialsException("用户不存在");
        }

        xcUser.setPassword(passwordEncoder.encode(password));
        userMapper.updateById(xcUser);

    }
}

dto:

@Data
@NoArgsConstructor
@AllArgsConstructor
public class PasswordParamDto {

    String cellphone;

    String email;

    String checkcodekey;

    String checkcode;

    String password;

    String confirmpwd;
}

6.2 注册(实战)

需求:为学生提供注册入口,通过此入口注册的用户为学生用户。

界面访问地址:http://www.51xuecheng.cn/register.html


接口:

手机验证码:/api/checkcode/phone?param1=手机号

注册:/api/auth/register

请求:

{
   cellphone:'',
   username:'',
   email:'',
   nickname:'',
   password:'',
   confirmpwd:'',
   checkcodekey:'',
   checkcode:''
}

响应:

200: 注册成功

其它:注册失败,失败原因使用统一异常处理返回的信息格式

执行流程:

1、校验验证码,如果不一致则抛出异常

2、校验两次密码是否一致,如果不一致则抛出异常

3、校验用户是否存在,如果存在则抛出异常

4、向用户表、用户角色关系表添加数据。角色为学生角色。

实现:

controller层:

 @PostMapping("/register")
    public void register(@RequestBody RegisterParamsDto registerParamsDto){
        userService.Register(registerParamsDto);
    }

service层:


@Service
public class UserServiceImpl implements UserService {

    @Autowired
    XcUserMapper userMapper;

    @Autowired
    XcUserRoleMapper xcUserRoleMapper;

    @Autowired
    StringRedisTemplate redisTemplate;

    @Autowired
    PasswordEncoder passwordEncoder;

    @Override
    @Transactional
    public void Register(RegisterParamsDto registerParamsDto) {
        String cellphone = registerParamsDto.getCellphone();
        String email = registerParamsDto.getEmail();
        if (StringUtils.isEmpty(cellphone) || StringUtils.isEmpty(email)) {
            throw new BadCredentialsException("手机号与邮箱不能为空");
        }

        String password = registerParamsDto.getPassword();
        String confirmpwd = registerParamsDto.getConfirmpwd();
        if (StringUtils.isEmpty(password) || StringUtils.isEmpty(confirmpwd)) {
            throw new BadCredentialsException("密码不能为空");
        }
        if (!password.equals(confirmpwd)) {
            throw new BadCredentialsException("两次密码不一致");
        }

        String checkcode = registerParamsDto.getCheckcode();
        if (StringUtils.isEmpty(checkcode)) {
            throw new BadCredentialsException("验证码不能为空");
        }
        String code = redisTemplate.opsForValue().get(cellphone);

        if (StringUtils.isEmpty(code)) {
            throw new BadCredentialsException("验证码已经失效");
        }
        if (!code.equals(checkcode)) {
            throw new BadCredentialsException("验证码不正确");
        }

        XcUser xcUser = null;
        xcUser = userMapper.selectOne(new LambdaQueryWrapper<XcUser>()
                .eq(XcUser::getCellphone, cellphone)
                .eq(XcUser::getEmail, email));

        if (xcUser != null) {
            throw new BadCredentialsException("用户已存在");
        }
        xcUser = new XcUser();
        BeanUtils.copyProperties(registerParamsDto, xcUser);
        xcUser.setCreateTime(LocalDateTime.now());
        xcUser.setUtype("101001");
        xcUser.setName(registerParamsDto.getNickname());
        xcUser.setPassword(passwordEncoder.encode(registerParamsDto.getPassword()));
        xcUser.setStatus("1");
        userMapper.insert(xcUser);
        XcUserRole xcUserRole = new XcUserRole();
        xcUserRole.setUserId(xcUser.getId());
        xcUserRole.setRoleId("17");
        xcUserRole.setCreateTime(LocalDateTime.now());
        xcUserRoleMapper.insert(xcUserRole);

    }
}


dto:

@Data
public class RegisterParamsDto extends XcUser {
    private String confirmpwd;
    private String checkcodekey;
    private String checkcode;
}

6.3 注:如果出现全局异常失效

请修改WebSecurityConfig类configure方法自定义异常处理:

   //配置安全拦截机制
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable();
        http
                .authorizeRequests()
                .antMatchers("/r/**").authenticated()//访问/r开始的请求需要认证通过
                .anyRequest().permitAll()//其它请求全部放行
                .and()
                .exceptionHandling()
                // 认证异常处理handler 使用的 lambda的内部的实现
                .authenticationEntryPoint((request, response, authenticationException) -> {
                    RestErrorResponse restErrorResponse = new RestErrorResponse(authenticationException.getMessage());
                    response.setStatus(401);
                    response.setContentType(MediaType.APPLICATION_json_UTF8_VALUE);
                    response.getWriter().println(json.tojsonString(restErrorResponse));

                })
                // 授权异常处理handler 使用的 lambda的内部的实现
                .accessDeniedHandler((request, response, accessDeniedException) -> {
                    RestErrorResponse restErrorResponse = new RestErrorResponse(accessDeniedException.getMessage());
                    response.setStatus(401);
                    response.setContentType(MediaType.APPLICATION_json_UTF8_VALUE);
                    response.getWriter().println(json.tojsonString(restErrorResponse));
                })
                .and()
                .formLogin().successForwardUrl("/login-success");//登录成功跳转到/login-success
    }

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐