OAuth2.0 实现单点登录


提示:以下是本篇文章正文内容,SpringCloud 系列学习将会持续更新

OAuth2.0 实现单点登录

注意: 第一次接触可能会比较难,不太好理解,需要多实践和观察。

前面我们虽然使用了统一存储来解决 Session 共享问题,但是我们发现就算实现了 Session 共享,依然存在一些问题,由于我们每个服务都有自己的验证模块,实际上整个系统是存在冗余功能的、同时还有我们上面出现的问题,那么能否实现只在一个服务进行登录,就可以访问其他的服务呢?
在这里插入图片描述
实际上之前的登录模式称为多点登录,而我们希望的是实现单点登陆,因此,我们得找一个更好的解决方案。

这里我们首先需要了解一种全新的登录方式:OAuth 2.0。我们经常看到一些网站支持第三方登录,比如淘宝、咸鱼我们就可以使用支付宝进行登录,腾讯游戏可以用QQ或是微信登陆,以及微信小程序都可以直接使用微信进行登录。我们知道它们并不是属于同一个系统,比如淘宝和咸鱼都不属于支付宝这个应用,但是由于需要获取支付宝的用户信息,这时我们就需要使用 OAuth2.0 来实现第三方授权,基于第三方应用访问用户信息的权限(本质上就是给别人调用自己服务接口的权限),那么它是如何实现的呢?

一、四种授权模式

我们还是从理论开始讲解,OAuth 2.0一共有四种授权模式:

  1. 客户端模式(Client Credentials)
     这是最简单的一种模式,我们可以直接向验证服务器请求一个 Token(这里可能有些小伙伴对Token的概念不是很熟悉,Token 相当于是一个令牌,我们需要在验证服务器 (User Account And Authentication) 服务拿到令牌之后,才能去访问资源,比如用户信息、借阅信息等,这样资源服务器才能知道我们是谁以及是否成功登录了)

     当然,这里的前端页面只是一个例子,它还可以是其他任何类型的客户端,比如 App、小程序甚至是第三方应用的服务。
    在这里插入图片描述
     虽然这种模式比较简便,但是已经失去了用户验证的意义,压根就不是给用户校验准备的,而是更适用于服务内部调用的场景。

  2. 密码模式(Resource Owner Password Credentials)
     密码模式相比客户端模式,就多了用户名和密码的信息,用户需要提供对应账号的用户名和密码,才能获取到 Token。
    在这里插入图片描述
     虽然这样看起来比较合理,但是会直接将账号和密码泄露给客户端,需要后台完全信任客户端不会拿账号密码去干其他坏事,所以这也不是我们常见的。

  3. 隐式授权模式(Implicit Grant)
     首先用户访问页面时,会重定向到认证服务器,接着认证服务器给用户一个认证页面,等待用户授权,用户填写信息完成授权后,认证服务器返回 Token。
    在这里插入图片描述
     它适用于没有服务端的第三方应用页面,并且相比前面一种形式,验证都是在验证服务器进行的,敏感信息不会轻易泄露,但是 Token 依然存在泄露的风险。

  4. 授权码模式(Authrization Code)
     这种模式是最安全的一种模式,也是推荐使用的一种,比如我们手机上的很多 App 都是使用的这种模式。

     相比隐式授权模式,它并不会直接返回 Token,而是返回授权码,真正的 Token 是通过应用服务器访问验证服务器获得的。在一开始的时候,应用服务器(客户端通过访问自己的应用服务器来进而访问其他服务)和验证服务器之间会共享一个 secret,这个东西没有其他人知道,而验证服务器在用户验证完成之后,会返回一个授权码,应用服务器最后将授权码和 secret 一起交给验证服务器进行验证,并且 Token 也是在服务端之间传递,不会直接给到客户端。
    在这里插入图片描述
     这样就算有人中途窃取了授权码,也毫无意义,因为,Token 的获取必须同时携带授权码和 secret ,但是 secret 第三方是无法得知的,并且 Token 不会直接丢给客户端,大大减少了泄露的风险。

但是乍一看,OAuth 2.0 不应该是那种第三方应用为了请求我们的服务而使用的吗,而我们这里需要的只是实现同一个应用内部服务之间的认证,其实我也可以利用 OAuth2.0 来实现单点登录,只是少了资源服务器这一角色,客户端就是我们的整个系统,接下来就让我们来实现一下。

回到目录…

二、搭建验证服务器

第一步就是最重要的,我们需要搭建一个验证服务器,它是我们进行权限校验的核心,验证服务器有很多的第三方实现也有 Spring 官方提供的实现,这里我们使用 Spring 官方提供的验证服务器。

这里提供了项目的码云地址

  • 项目一(脚手架):基础项目,没有使用 SpringCloud 组件,使用 RestTemplate 实现远程调用,可以直接运行,跟着文章一起做
  • 项目二(脚手架):基础项目,使用了 Nacos 注册中心,需要本地安装 Nacos 才能运行,跟着文章一起做 (推荐使用该项目)
  • 项目三(实现版):实现了文章中的单点登录,安装 Nacos 后可以运行,可以直接看效果

注意:以上项目都没有实现前端,所以过程中需要 Postman 或 Apifox 等接口工具验证

如果我们使用了项目一,则需要在父项目的 pom 中添加 SpringCloud 依赖版本管理:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-dependencies</artifactId>
    <version>2021.0.1</version>
    <type>pom</type>
    <scope>import</scope>
</dependency>

①接着创建一个新的项目模块 auth-service,添加依赖:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!--  OAuth2.0依赖,不再内置了,所以得我们自己指定一下版本  -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-oauth2</artifactId>
        <version>2.2.5.RELEASE</version>
    </dependency>
    <!-- 引入security给我们提供登录功能 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
</dependencies>

②设置 yml 配置文件

server:
  port: 8500
  servlet:
  	#为了防止一会在服务之间跳转导致Cookie打架(因为所有服务地址都是localhost,都会存JSESSIONID)
  	#这里修改一下context-path,这样保存的Cookie会使用指定的路径,就不会和其他服务打架了
  	#但是注意之后的请求都得在最前面加上这个路径
    context-path: /sso

③接着我们需要编写一个 SpringSecurity 的配置类 SecurityConfiguration.java

@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin().permitAll(); // 使用表单登录
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
        auth
                .inMemoryAuthentication()   // 直接创建一个用户,懒得搞数据库了
                .passwordEncoder(encoder)
                .withUser("wsy").password(encoder.encode("123456")).roles("admin");
    }

    @Bean   // 这里需要将AuthenticationManager注册为Bean,在OAuth配置中使用
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

④再编写一个 OAuth2 的配置类 OAuth2Configuration.java:配置客户端

@EnableAuthorizationServer   //开启验证服务器
@Configuration
public class OAuth2Configuration extends AuthorizationServerConfigurerAdapter {
    @Resource
    private AuthenticationManager manager;

    private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();

    /**
     * 这个方法是对客户端进行配置,一个验证服务器可以预设很多个客户端,
     * 之后这些指定的客户端就可以按照下面指定的方式进行验证
     * @param clients 客户端配置工具
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients
                .inMemory()   // 这里我们直接硬编码创建,当然也可以像Security那样自定义或是使用JDBC从数据库读取
                .withClient("web")   // 客户端ID,随便起就行
                .secret(encoder.encode("654321"))      // 只与客户端分享的secret,随便写,但是注意要加密
                .autoApprove(false)    // 自动审批,这里关闭,要的就是一会体验那种感觉
                .scopes("book", "user", "borrow")     // 授权范围,这里我们使用全部all
                .authorizedGrantTypes("client_credentials", "password", "implicit", "authorization_code", "refresh_token");
                //授权模式,一共支持5种,除了之前我们介绍的四种之外,还有一个刷新Token的模式
                //这里我们直接把五种都写上,方便一会实验,当然各位也可以单独只写一种一个一个进行测试
                //现在我们指定的客户端就支持这五种类型的授权方式了
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) {
        security
                .passwordEncoder(encoder)    // 编码器设定为BCryptPasswordEncoder
                .allowFormAuthenticationForClients()  // 允许客户端使用表单验证,一会我们POST请求中会携带表单信息
                .checkTokenAccess("permitAll()");     // 允许所有的Token查询请求
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        endpoints
                .authenticationManager(manager);
        // 由于SpringSecurity新版本的一些底层改动,这里需要配置一下authenticationManager,才能正常使用password模式
    }
}

⑤启动服务器
在这里插入图片描述

回到目录…

三、接口工具测试

接下来我们使用 Postman 或 Apifox 等工具将4种模式逐一测试:

🍀3.1 客户端模式 (Client Credentials)

客户端模式只需要提供 idsecret 即可直接拿到 Token,默认请求路径为 http://localhost:8500/sso/oauth/token
在这里插入图片描述
发起请求后,可以看到我们得到了 Token,它是以 JSON 格式给到我们的:

{
    "access_token": "10f6a9ea-62fc-4cf1-b48f-a0cf1ae75250",
    "token_type": "bearer",
    "expires_in": 42852,
    "scope": "book user borrow"
}

然后我们可以检验 Token 是否有效: http://localhost:8500/sso/oauth/check_token?token=token值
在这里插入图片描述
可以看到 active 为 true 时,表示我们刚刚申请到的 Token 是有效的。

{
    "scope": [
        "book",
        "user",
        "borrow"
    ],
    "active": true,
    "exp": 1700880810,
    "client_id": "web"
}

回到目录…

🍀3.2 密码模式 (Resource Owner Password Credentials)

接着我们来测试密码模式,我们在请求体 Body 中提供具体的用户名和密码:
在这里插入图片描述
同时还要在请求头 Authorization 中添加 Basic Auth 验证信息,参数是客户端的 id 和 secret:
在这里插入图片描述
当我们发起请求时会自动生成 Basic 验证相关内容,实际上请求头中携带的参数如下示例:
在这里插入图片描述
响应成功,得到 Token 信息,并且这里还多出了一个 refresh_token,这是用于刷新 Token 的,我们之后会进行讲解。

{
    "access_token": "8f87d827-6ae9-4709-8ba5-d300f4db3b8a",
    "token_type": "bearer",
    "refresh_token": "58632c51-1e63-4f33-a696-81bd76bf520c",
    "expires_in": 43199,
    "scope": "book user borrow"
}

当我们查询 Token 信息时,还可以看到登录的具体用户以及角色权限等。

{
    "active": true,
    "exp": 1700882988,
    "user_name": "wsy",
    "authorities": [
        "ROLE_admin"
    ],
    "client_id": "web",
    "scope": [
        "book",
        "user",
        "borrow"
    ]
}

回到目录…

🍀3.3 隐式授权模式 (Implicit Grant)

接着我们来看隐式授权模式,这种模式我们需要在验证服务器上进行登录操作,而不是直接请求Token。我们可以直接在网页中访问http://localhost:8500/sso/oauth/authorize?client_id=web&response_type=token

注意 response_type 一定要是 token 类型,这样才会直接返回 Token。当浏览器发起请求后,可以看到熟悉而又陌生的界面,没错,这就是我们在 auth-service 模块中引入的 security 帮我们实现了登录功能:
在这里插入图片描述

但是登录之后我们发现返回了一个错误“必须至少向客户端注册一个redirect_uri”
在这里插入图片描述
原因:这是因为登录成功之后,验证服务器需要将结果返回给客户端,所以需要在配置文件中设置客户端的回调地址。这样浏览器就会被重定向到指定的回调地址,并且请求中会携带 Token 信息。

现在我们在 OAuth2Configuration 配置类中设置回调地址 redirectUris:

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    clients
            .inMemory()
            .withClient("web")
            .secret(encoder.encode("654321"))
            .autoApprove(false)
            .scopes("book", "user", "borrow")
            .redirectUris("http://localhost:8081/book/1") // 可以写多个,当有多个时需要在验证请求中指定使用哪个地址进行回调
            .authorizedGrantTypes("client_credentials", "password", "implicit", "authorization_code", "refresh_token");
}

接着重启验证服务器,再次访问可以看到这里会让我们选择哪些范围进行授权:
在这里插入图片描述
当我们点击授权后,会跳转到我们指定的客户端回调地址,并且在url中携带了 token 信息 (包括 Token值、Token类型、过期时间、作用域)。
在这里插入图片描述
同样可以检验一下 Token 是否有效:

{
    "active": true,
    "exp": 1700942201,
    "user_name": "wsy",
    "authorities": [
        "ROLE_admin"
    ],
    "client_id": "web",
    "scope": [
        "book",
        "borrow",
        "user"
    ]
}

回到目录…

🍀3.4 授权码模式 (Authrization Code)

最后我们来看看最安全的授权码模式,这种模式其实流程和上面是一样的,但是请求的是 code 类型:http://localhost:8500/sso/oauth/authorize?client_id=web&response_type=code

当我们把上面隐式授权模式的登录流程走完后,我们回调后的地址如下,可以看到此时的 URL 中没有传递 Token,而是授权码 code每次回调的 code 值并不一样
在这里插入图片描述

按照我们之前讲解的原理,我们需要携带授权码和 secret 一起请求,才能拿到 Token,正常情况下是由回调的服务器进行处理,这里我们就在 Postman 中进行,我们复制刚刚得到的授权码,接口依然是http://localhost:8500/sso/oauth/token
在这里插入图片描述
可以看到结果也是正常返回了 Token 信息:

{
    "access_token": "950e9bdd-3525-4e78-acf7-f86e97d3ed01",
    "token_type": "bearer",
    "refresh_token": "4d8ee0cc-6bae-4c71-8666-0d86e78fc0ff",
    "expires_in": 42331,
    "scope": "book borrow user"
}

这样我们四种最基本的 Token 请求方式就实现了。

回到目录…

🍀3.5 令牌刷新

当我们的 Token 过期时,我们就可以使用这个 refresh_token 来申请一个新的 Token:
在这里插入图片描述
但是执行之后我们发现会直接出现一个内部错误:

{
	"error": "server_error",
	"error_description": "Internal Server Error"
}

在这里插入图片描述
我们需要在 SecurityConfiguration 配置类中添加一个 UserDetailsService 配置,并注册为 Bean:

@Bean
@Override
public UserDetailsService userDetailsServiceBean() throws Exception {
    return super.userDetailsServiceBean(); // 上面已经配置好了
}

然后在 OAuth2Configuration 配置类的 Endpoint 中添加一条:

@Resource
UserDetailsService service;

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
    endpoints
            .userDetailsService(service) // 新增的配置
            .authenticationManager(manager);
}

最后再次尝试刷新 Token:

{
    "access_token": "857f57b5-34a6-47c9-9971-203b75885bec",
    "token_type": "bearer",
    "refresh_token": "4d8ee0cc-6bae-4c71-8666-0d86e78fc0ff",
    "expires_in": 43200,
    "scope": "book borrow user"
}

OK,成功刷新 Token,返回了一个新的。

回到目录…

四、基于 @EnableOAuth2Sso 实现

前面我们将验证服务器已经搭建完成了,现在我们将我们的服务作为单点登录应用直接实现单点登录,SpringCloud 为我们提供了客户端的直接实现,我们只需要添加一个注解和少量配置即可将我们的服务作为一个单点登陆应用,使用的是第四种授权码模式

一句话来说就是,这种模式只是将验证方式由原本的默认登录形式改变为了统一在授权服务器登录的形式。

🍇4.1 微服务实现单点登录

①首先 bookService、userService、borrowService 三个客户端添加依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-oauth2</artifactId>
    <version>2.2.5.RELEASE</version>
</dependency>

②在客户端的启动类上添加 @EnableOAuth2Sso 注解:

@EnableOAuth2Sso
@SpringBootApplication
public class BookApplication {
    public static void main(String[] args) {
        SpringApplication.run(BookApplication.class, args);
    }
}

③客户端的配置文件:

security:
  oauth2:
    client:
      #不多说了
      client-id: web
      client-secret: 654321
      #Token获取地址
      access-token-uri: http://localhost:8500/sso/oauth/token
      #验证页面地址
      user-authorization-uri: http://localhost:8500/sso/oauth/authorize
    resource:
      #Token信息获取和校验地址
      token-info-uri: http://localhost:8500/sso/oauth/check_token

④此时我们还要修改 OAuth2Configuration 配置类中的回调地址 redirectUris: 改为 Security 帮我们实现的 /login

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
    clients
            .inMemory()
            .withClient("web")
            .secret(encoder.encode("654321"))
            .autoApprove(false)
            .scopes("book", "user", "borrow")
            // 因为3个服务都添加 Security,而 Security 的默认登录地址为 /login
            .redirectUris("http://localhost:8081/login", "http://localhost:8082/login", "http://localhost:8083/login")
            .authorizedGrantTypes("client_credentials", "password", "implicit", "authorization_code", "refresh_token");
}

如果不修改回调地址,则通过验证服务器后会产生如下报错:不知为何 OAuth 会自动回调到 /login 地址
在这里插入图片描述

⑤重启所有服务后,我们可以尝试访问图书服务接口:http://localhost:8081/book/5,它会给我们重定向到验证服务器让我们登录 http://localhost:8500/sso/login
在这里插入图片描述
登录后,它又将我们重定向到该页面,我们可以给访问页面进行授权:
在这里插入图片描述

可以看到在发现没有登录验证时,会直接跳转到授权页面,进行授权登录,之后才可以继续访问图书服务:
在这里插入图片描述

那么用户信息呢?是否也一并保存过来了?我们这里直接获取一下 SpringSecurity 的 Context 查看用户信息:

@RequestMapping("/book/{bid}")
Book findBookById(@PathVariable("bid") int bid){
  	//通过SecurityContextHolder将用户信息取出
    SecurityContext context = SecurityContextHolder.getContext();
    System.out.println(context.getAuthentication());
    return service.getBookById(bid);
}

可以看到信息:
在这里插入图片描述
这里使用的不是之前的UsernamePasswordAuthenticationToken也不是RememberMeAuthenticationToken,而是新的OAuth2Authentication,它保存了验证服务器的一些信息,以及经过我们之前的登陆流程之后,验证服务器发放给客户端的 Token 信息,并通过 Token 信息在验证服务器进行验证获取用户信息,最后保存到 Session 中,表示用户已验证,所以本质上还是要依赖浏览器存 Cookie 的。

回到目录…

🍇4.2 SESSION 不同步问题

但是我们发现一个问题,就是由于SESSION不同步,每次切换不同的服务进行访问都会重新导验证服务器去验证一次:

在这里插入图片描述
在这里插入图片描述

解决方案:
方案一:像之前一样做 SESSION 统一存储
方案二:设置 context-path 路径,每个服务单独设置,就不会打架了

但是这样依然没法解决服务间调用的问题,所以仅仅依靠单点登录的模式不太行。 我们下面将会使用 @EnableResourceServer 解决远程调用的问题。
在这里插入图片描述

回到目录…

五、基于 @EnableResourceServer 实现 🚩

前面我们讲解了将我们的服务作为单点登录应用直接实现单点登录,那么现在我们如果是以第三方应用进行访问呢?这时我们就需要将我们的服务作为资源服务了,作为资源服务就不会再提供验证的过程,而是直接要求请求时携带 Token,而验证过程我们这里就继续用 Apifox 来完成,这才是我们常见的模式。

一句话来说,跟上面相比,我们只需要携带 Token 就能访问这些资源服务器了,客户端被独立了出来,用于携带 Token 去访问这些服务。

🍬5.1 资源服务器

①首先还是给3个客户端添加依赖 (之前已经添加了):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-oauth2</artifactId>
    <version>2.2.5.RELEASE</version>
</dependency>

②在客户端的启动类上添加 @EnableResourceServer 注解:

@EnableResourceServer
@SpringBootApplication
public class BookApplication {
    public static void main(String[] args) {
        SpringApplication.run(BookApplication.class, args);
    }
}

③客户端的配置文件:

security:
  oauth2:
    client:
      client-id: web
      client-secret: 654321
    resource:
    	# 因为资源服务器得验证你的Token是否有访问此资源的权限以及用户信息,所以只需要一个验证地址
      token-info-uri: http://localhost:8500/sso/oauth/check_token

回到目录…

🍬5.2 客户端访问

①配置完成后,我们启动服务器,直接访问会发现:
在这里插入图片描述

②这是由于我们的请求头中没有携带 Token 信息 (可以采用 密码模式客户端模式 获取 Token)。然后再访问资源:

  • 方案一:直接在 URL 后面携带 Token http://localhost:8081/book/1?access_token=287d5e9c-7e8c-4d4d-b8b0-c45530f0641a
    在这里插入图片描述

  • 方案二:在请求头中添加 Authorization,值为 Bearer + Token
    在这里插入图片描述
    访问成功:
    在这里插入图片描述

③我们还可以对资源服务器进行深度自定义,例如我们在 bookService 服务中添加一个配置类 ResourceConfiguration.java,希望用户授权了某个 Scope 权限才可以访问此服务:

@Configuration
public class ResourceConfiguration extends ResourceServerConfigurerAdapter { //继承此类进行高度自定义

    @Override
    public void configure(HttpSecurity http) throws Exception {  //这里也有HttpSecurity对象,方便我们配置SpringSecurity
        http
                .authorizeRequests()
                .anyRequest().access("#oauth2.hasScope('book')");  //添加自定义规则
      					//Token必须要有我们自定义scope授权才可以访问此资源
    }
}

配置文件中的权限一定要对应在 client 中设置的权限:
在这里插入图片描述

可以看到当没有对应的 scope 授权时,那么会直接返回 insufficient_scope 错误:
在这里插入图片描述

④尝试获取 Session 中的 Security 信息:运行访问会报错

@GetMapping("/book/{bid}")
public Book findBookById(@PathVariable("bid") int bid, HttpSession session) {
    // 通过SecurityContextHolder将用户信息取出
    SecurityContext context = (SecurityContext) session.getAttribute("SPRING_SECURITY_CONTEXT");
    System.out.println(context.getAuthentication());
    return bookService.getBookById(bid);
}

可以发现,实际上资源服务器完全没有必要将 Security 的信息保存在 Session 中了,因为现在只需要将 Token 告诉资源服务器,那么资源服务器就可以联系验证服务器,得到用户信息,就不需要使用之前的 Session 存储机制了,所以你会发现 HttpSession 中没有 SPRING_SECURITY_CONTEXT,现在 Security 信息都是通过连接资源服务器获取。

回到目录…

🍬5.3 解决远程调用

方案一:使用 OAuth2RestTemplate 远程调用

如果我们没有将服务注册到 Nacos 中,而是采用传统的 RestTemplate 实现的远程调用。则此时我们可以直接改用 OAuth2RestTemplate,它继承自 RestTemplate,会在请求其他服务时携带当前请求的 Token 信息。

①先搞个配置文件注册 Bean

@Configuration
public class RestTemplateConfig {
    
    @Resource
    OAuth2ClientContext context;

    @Bean
    @LoadBalanced  // 负载均衡
    public OAuth2RestTemplate restTemplate(){
        return new OAuth2RestTemplate(new ClientCredentialsResourceDetails(), context);
    }
}

②远程调用时替换掉 RestTemplate 即可:

@Service
public class BorrowServiceImpl implements BorrowService {
    @Resource
    private BorrowMapper borrowMapper;
    @Resource
    private OAuth2RestTemplate restTemplate;

    @Override
    public UserBorrowView getBorrowViewByUid(int uid) {
        List<Borrow> borrowList = borrowMapper.getBorrowsByUid(uid);
        User user = restTemplate.getForObject("http://userservice/user/" + uid, User.class);
        List<Book> bookList = borrowList
                .stream()
                .map(b -> restTemplate.getForObject("http://bookservice/book/" + b.getBid(), Book.class))
                .collect(Collectors.toList());
        return new UserBorrowView(user, bookList);
    }
}

可以看到服务成功调用了:同样,请求头中必须携带 Token 信息
在这里插入图片描述

方案二:通过 Feign 实现远程调用

如果我们都为服务注册到 Nacos 中,并且采用 Feign 实现了远程调用。则我们可以直接开启 Feign 对 OAuth 的支持,官方教程https://docs.spring.io/spring-cloud-openfeign/docs/current/reference/html/#oauth2-support,只需要在 borrowService 服务中添加如下配置即可:

feign:
  oauth2:
  	# 开启Oauth支持,这样就会在请求头中携带Token了
    enabled: true
    # 同时开启负载均衡支持
    load-balanced: true

重启服务器,可以看到结果OK了:
在这里插入图片描述

这样我们就成功将之前的三个服务作为资源服务器了,然后让 (浏览器、小程序、App、第三方服务等) 作为客户端来访问,并且也是需要先去验证服务器进行验证然后再通过携带 Token 进行访问,这种模式是我们比较常见的模式

回到目录…

六、使用 JWT 存储 Token

官网https://jwt.io

JSON Web Token 令牌(JWT)是一个开放标准(RFC 7519),它定义了一种紧凑和自成一体的方式,用于在各方之间作为 JSON 对象安全地传输信息。这些信息可以被验证和信任,因为它是数字签名的。JWT 可以使用密钥(使用 HMAC 算法)或使用 RSA 或 ECDSA 进行公钥/私钥对进行签名。

实际上,我们之前都是携带 Token 向资源服务器发起请求后,资源服务器由于不知道我们 Token 的用户信息,所以需要向验证服务器询问此 Token 的认证信息,这样才能得到 Token 代表的用户信息,但是各位是否考虑过,如果每次用户请求都去查询用户信息,那么在大量请求下,验证服务器的压力可能会非常的大。而使用 JWT 之后,Token 中会直接保存用户信息,这样资源服务器就不再需要询问验证服务器,自行就可以完成解析,我们的目标是不联系验证服务器就能直接完成验证。

JWT 令牌的格式如下:
在这里插入图片描述

一个 JWT 令牌由三部分组成:标头(Header)、有效载荷(Payload) 和 签名(Signature)。在传输的时候,会将 JWT 的3部分分别进行 Base64 编码后进行连接形成最终需要传输的字符串。

  • 标头:包含一些元数据信息,比如 JWT 签名所使用的加密算法,还有类型,这里统一都是 JWT。
  • 有效载荷:包括用户名称、令牌发布时间、过期时间、JWT ID 等,当然我们也可以自定义添加字段,我们的用户信息一般都在这里存放。
  • 签名:首先需要指定一个密钥,该密钥仅仅保存在服务器中,保证不能让其他用户知道。然后使用 Header 中指定的算法对 Header 和 Payload 进行 base64 加密之后的结果通过密钥计算哈希值,然后就得出一个签名哈希。这个会用于之后验证内容是否被篡改。

知识补充

  1. Base64:就是包括小写字母a-z大写字母A-Z数字0-9符号"+""/" 一共 64 个字符的字符集(末尾还有1个或多个 = 用来凑够字节数),任何的符号都可以转换成这个字符集中的字符,这个转换过程就叫做 Base64编码,编码之后会生成只包含上述 64 个字符的字符串。相反,如果需要原本的内容,我们也可以进行 Base64解码,回到原有的样子。
    在这里插入图片描述
    注意:Base64 不是加密算法,只是一种信息的编码方式而已。

  2. 加密算法:加密算法分为对称加密和非对称加密,其中对称加密(Symmetric Cryptography)比较好理解,就像一把锁配了两把钥匙一样,这两把钥匙你和别人都有一把,然后你们直接传递数据,都会把数据用锁给锁上,就算传递的途中有人把数据窃取了,也没办法解密,因为钥匙只有你和对方有,没有钥匙无法进行解密,但是这样有个问题,既然解密的关键在于钥匙本身,那么如果有人不仅窃取了数据,而且对方那边的治安也不好,于是顺手就偷走了钥匙,那你们之间发的数据不就凉凉了吗。

    因此,非对称加密(Asymmetric Cryptography) 算法出现了,它并不是直接生成一把钥匙,而是生成一个公钥和一个私钥,私钥只能由你保管,而公钥交给对方或是你要发送的任何人都行,现在你需要把数据传给对方,那么就需要使用私钥进行加密,但是,这个数据只能使用对应的公钥进行解密,相反,如果对方需要给你发送数据,那么就需要用公钥进行加密,而数据只能使用私钥进行解密,这样的话就算对方的公钥被窃取,那么别人发给你的数据也没办法解密出来,因为需要私钥才能解密,而只有你才有私钥。

    因此,非对称加密的安全性会更高一些,包括HTTPS的隐私信息正是使用非对称加密来保障传输数据的安全(当然HTTPS并不是单纯地使用非对称加密完成的,感兴趣的可以去了解一下)

    对称加密和非对称加密都有很多的算法,比如对称加密,就有:DES、IDEA、RC2,非对称加密有:RSA、DAS、ECC

  3. 不可逆加密算法:常见的不可逆加密算法有MD5, HMAC, SHA-1, SHA-224, SHA-256, SHA-384, 和SHA-512, 其中SHA-224、SHA-256、SHA-384,和SHA-512我们可以统称为SHA2加密算法,SHA加密算法的安全性要比MD5更高,而SHA2加密算法比SHA1的要高,其中SHA后面的数字表示的是加密后的字符串长度,SHA1默认会产生一个160位的信息摘要。经过不可逆加密算法得到的加密结果,是无法解密回去的,也就是说加密出来是什么就是什么了。本质上,其就是一种哈希函数,用于对一段信息产生摘要,以防止被篡改。

    实际上这种算法就常常被用作信息摘要计算,同样的数据通过同样的算法计算得到的结果肯定也一样,而如果数据被修改,那么计算的结果肯定就不一样了。

这里我们就可以利用 jwt,将我们的 Token 采用新的方式进行存储:
在这里插入图片描述

回到目录…

🍒6.1 验证服务器

这里我们使用最简单的一种方式,对称密钥,我们需要对验证服务器的配置进行修改:

①向 SecurityConfiguration.java 配置类添加两个 Bean(这里设置的密钥待会儿要和资源服务器对应)

@Bean("tokenConverter")
public JwtAccessTokenConverter tokenConverter(){  // Token转换器,将其转换为JWT
    JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
    converter.setSigningKey("wsyKey");   // 这个是对称密钥,一会资源服务器那边也要指定为这个
    return converter;
}

@Bean
public TokenStore tokenStore(@Qualifier("tokenConverter") JwtAccessTokenConverter converter){  // Token存储方式现在改为JWT存储
    return new JwtTokenStore(converter);  // 传入刚刚定义好的转换器
}

②向 OAuth2Configuration.java 配置类修改和添加配置方法:

@Resource
TokenStore store;
@Resource
JwtAccessTokenConverter converter;

// 添加新的配置方法
private AuthorizationServerTokenServices serverTokenServices(){  // 这里对AuthorizationServerTokenServices进行一下配置
    DefaultTokenServices services = new DefaultTokenServices();
    services.setSupportRefreshToken(true);   // 允许Token刷新
    services.setTokenStore(store);   // 添加刚刚的TokenStore
    services.setTokenEnhancer(converter);   // 添加Token增强,其实就是JwtAccessTokenConverter,增强是添加一些自定义的数据到JWT中
    return services;
}

// 修改原来的配置方法
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
    endpoints
            .tokenServices(serverTokenServices())   //设定为刚刚配置好的AuthorizationServerTokenServices
            .userDetailsService(service)
            .authenticationManager(manager);
}

然后我们就可以重启验证服务器,并采用 密码模式客户端模式 获取 Token:http://localhost:8500/sso/oauth/token

{
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6WyJib29rIiwidXNlciIsImJvcnJvdyJdLCJleHAiOjE3MDA5NTIzMTUsImp0aSI6IjZlMjFhM2YzLWIyZDYtNDMyYy1hYTUwLTdkOWMwMWFjYWM3MCIsImNsaWVudF9pZCI6IndlYiJ9.pGGyJ6t38IfGku9E8MMw8AcNvrAb3Tq249-4Ke5brIo",
    "token_type": "bearer",
    "expires_in": 43199,
    "scope": "book user borrow",
    "jti": "6e21a3f3-b2d6-432c-aa50-7d9c01acac70"
}

可以看到成功获取了 AccessToken,但是这里的格式跟我们之前的格式就大不相同了,因为现在它是 JWT 令牌,我们可以对其进行一下 Base64解码:
在这里插入图片描述

🍒6.2 资源服务器

现在我们对三个资源服务器进行配置:将之前的 security 配置更换如下

security:
  oauth2:
    resource:
      jwt:
        key-value: wsyKey # 注意这里要跟验证服务器的密钥一致,这样算出来的签名才会一致

重启资源服务器,调用接口发现:访问成功!
在这里插入图片描述
在这里插入图片描述

🍒6.3 令牌中继 (远程调用)

如果我们直接用 JWT 进行远程调用时,就会发现如下报错:访问此资源需要完全身份验证

feign.FeignException$Unauthorized: [401] during [GET] to [http://userservice/user/1] [UserClient#findUserById(int)]: [{"error":"unauthorized","error_description":"Full authentication is required to access this resource"}]

所以我们需要在 borrowservice 模块添加一个拦截器 RequestInterceptorConfig.java,实现令牌中继

@Configuration
public class RequestInterceptorConfig implements RequestInterceptor {

    @Override
    public void apply(RequestTemplate requestTemplate) {
        OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails)
                SecurityContextHolder.getContext().getAuthentication().getDetails();
        requestTemplate.header("Authorization","Bearer" + details.getTokenValue());
    }
}

request Headers 标准的请求头

  • Authorization:在HTTP中,服务器可以对一些资源进行认证保护,如果你要访问这些资源,就要提供用户名和密码,这个用户名和密码就是在 Authorization 头中附带的,格式是 Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9......

JWT 授权为啥要在 Authorization 标头里加个 Bearer 呢?

  • 设计 API 授权,或者调用第三方 API 时,经常会接触到:Authorization : Bearer Token
  • 这是因为 W3C 的 HTTP 1.0 规范格式是:Authorization: <type> <authorization-parameters>

所以 Bearer 是授权的类型,常见的授权类型还有:

  • Basic 用于 http-basic 认证;
  • Bearer 常见于 OAuth 和 JWT 授权;
  • Digest MD5 哈希的 http-basic 认证 (已弃用)
  • AWS4-HMAC-SHA256 AWS 授权

JWT官方推荐格式https://jwt.io/introduction

再次重启,访问资源:远程调用成功!
在这里插入图片描述

回到目录…

总结

简单地说,cookie、session、token (JWT)之间的关系?

  1. 当用户在浏览器登录一个网站后,服务器会生成一个 session,并将 Session ID 发给浏览器。
  2. 浏览器 (客户端) 下次访问该网站的任何页面时,会把 Session ID 放入 cookie 中,发给服务器。
  3. 服务器收到后,会查看内存中是否有该 session。如果有,则可以浏览,反之重新登录。但是这种模式只适合单服务器,多服务器就没法查了。如果做Session服务器又怕单点失效,集群效率又低。
  4. 此时,token 就可以解决这个问题。 当用户登录后,服务器直接提供给浏览器一个 token。下次浏览器访问任何一个服务时,只需要提供这个 token 就可以了。
  5. token 是经过服务器签名的,任何服务器都可以识别出这个签名。

OAuth2.0 如何实现第三方登录授权?

  1. 假如现在你想登录小红书,但又不想注册,你可以选择使用微信登录,此时就发起了 OAuth2.0 的授权流程。
  2. 此时,小红书会带着相关证明向微信申请获取该用户的资料许可。
  3. 微信审核通过后,会发给小红书一个 token 令牌。
  4. 小红书就可以拿着这个 token 向微信的数据服务器发送请求获取用户的昵称等资料,然后显示到页面中。

回到目录…


总结:
提示:这里对文章进行总结:
本文是对OAuth2.0的学习,了解了它的4种授权方式,学会了如何搭建验证服务器,使用 postman对四种模式作了接口测试。并且学会了资源服务器实现单点登录的两种方式,也成功解决了远程调用时没有携带 token 的问题。最后也学会了使用 JWT 存储 Token,大大提高了安全性。

  • 65
    点赞
  • 308
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 26
    评论
实现OAuth 2.0的单点登录(Single Sign-On,SSO),你可以按照以下步骤进行操作: 1. 设计用户认证和授权系统:首先,你需要设计一个用户认证和授权系统,以便为用户提供登录和授权服务。这个系统可以是一个独立的身份提供者(Identity Provider,IdP),也可以与现有的认证系统集成。 2. 注册应用程序:在你的身份提供者上注册应用程序,并为每个需要单点登录的应用程序生成一个客户端ID和密钥。这些凭据将用于应用程序与身份提供者之间的通信。 3. 配置身份提供者:在身份提供者上配置单点登录设置,以允许应用程序使用OAuth 2.0进行身份验证和授权。这通常涉及配置重定向URL、访问范围和其他权限参数。 4. 应用程序集成:将单点登录功能集成到每个需要SSO的应用程序中。这通常涉及使用OAuth 2.0的授权流程来获取访问令牌,以及验证和解析令牌以确认用户身份。 5. 令牌管理:在应用程序中,你需要实现令牌管理逻辑,包括令牌的存储、刷新和吊销。这样可以确保令牌的有效性,并提供良好的用户体验。 6. 实现单点注销:除了单点登录,你还可以实现单点注销功能,允许用户在一个应用程序中注销时自动注销所有相关的应用程序。 以上是一个基本的步骤概述。具体实现细节可能因身份提供者、编程语言和框架的不同而有所差异。你可以参考特定身份提供者的文档,并查阅相关的OAuth 2.0实施指南来获得更详细的信息。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 26
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

一只咸鱼。。

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值