Oauth2 终于看懂了

基本知识

Oauth是什么

简单说,OAuth 就是一种授权机制。数据的所有者告诉系统,同意授权第三方应用进入系统,获取这些数据。系统从而产生一个短期的进入令牌(token),用来代替密码,供第三方应用使用,OAuth 的核心就是向第三方应用颁发令牌。Oauth2是Oauth的第二个版本。简易理解参考大佬文章http://www.ruanyifeng.com/blog/2019/04/oauth_design.html

相关名词解释

  • 第三方应用程序(Third-party application): 又称之为客户端(client),比如我们的产品想要使用 QQ、微信等第三方登录。对我们的产品来说,QQ、微信登录是第三方登录系统。我们又需要第三方登录系统的资源(头像、昵称等)。对于 QQ、微信等系统我们又是第三方应用程序。
  • HTTP 服务提供商(HTTP service): 我们的云笔记产品以及 QQ、微信等都可以称之为“服务提供商”。
  • 资源所有者(Resource Owner): 又称之为用户(user)。
  • 用户代理(User Agent): 比如浏览器,代替用户去访问这些资源。
  • 认证服务器(Authorization server): 即服务提供商专门用来处理认证的服务器,简单点说就是登录功能(验证用户的账号密码是否正确以及分配相应的权限)
  • 资源服务器(Resource server): 即服务提供商存放用户生成的资源的服务器。它与认证服务器,可以是同一台服务器,也可以是不同的服务器。简单点说就是资源的访问入口。

交互过程

oAuth 在 “客户端” 与 “服务提供商” 之间,设置了一个授权层(authorization layer)。“客户端” 不能直接登录 “服务提供商”,只能登录授权层,以此将用户与客户端区分开来。“客户端” 登录授权层所用的令牌(token),与用户的密码不同。用户可以在登录的时候,指定授权层令牌的权限范围和有效期。“客户端” 登录授权层以后,“服务提供商” 根据令牌的权限范围和有效期,向 “客户端” 开放用户储存的资料。
在这里插入图片描述

令牌的访问与刷新

Access Token

Access Token 是客户端访问资源服务器的令牌。拥有这个令牌代表着得到用户的授权。然而,这个授权应该是 临时 的,有一定有效期。这是因为,Access Token 在使用的过程中 可能会泄露。给 Access Token 限定一个 较短的有效期 可以降低因 Access Token 泄露而带来的风险。

然而引入了有效期之后,客户端使用起来就不那么方便了。每当 Access Token 过期,客户端就必须重新向用户索要授权。这样用户可能每隔几天,甚至每天都需要进行授权操作。这是一件非常影响用户体验的事情。希望有一种方法,可以避免这种情况。

于是 oAuth2.0 引入了 Refresh Token 机制

Refresh Token

Refresh Token 的作用是用来刷新 Access Token。认证服务器提供一个刷新接口,例如:

http://www.funtl.com/refresh?refresh_token=&client_id=

传入 refresh_token 和 client_id,认证服务器验证通过后,返回一个新的 Access Token。为了安全,oAuth2.0 引入了两个措施:

  • oAuth2.0 要求,Refresh Token 一定是保存在客户端的服务器上 ,而绝不能存放在狭义的客户端(例如 App、PC 端软件)上。调用 refresh 接口的时候,一定是从服务器到服务器的访问。
  • oAuth2.0 引入了 client_secret 机制。即每一个 client_id 都对应一个 client_secret。这个 client_secret 会在客户端申请 client_id 时,随 client_id 一起分配给客户端。客户端必须把 client_secret 妥善保管在服务器上,决不能泄露。刷新 Access Token 时,需要验证这个 client_secret。

实际上的刷新接口类似于:

http://www.funtl.com/refresh?refresh_token=&client_id=&client_secret=

以上就是 Refresh Token 机制。Refresh Token 的有效期非常长,会在用户授权时,随 Access Token 一起重定向到回调 URL,传递给客户端。

客户端授权模式

客户端必须得到用户的授权(authorization grant),才能获得令牌(access token)。oAuth 2.0 定义了四种授权方式。

  • implicit:简化模式,不推荐使用
  • authorization code:授权码模式
  • resource owner password credentials:密码模式
  • client credentials:客户端模式
简化模式

简化模式适用于纯静态页面应用。所谓纯静态页面应用,也就是应用没有在服务器上执行代码的权限(通常是把代码托管在别人的服务器上),只有前端 JS 代码的控制权。

这种场景下,应用是没有持久化存储的能力的。因此,按照 oAuth2.0 的规定,这种应用是拿不到 Refresh Token 的。其整个授权流程如下:
在这里插入图片描述
该模式下,access_token 容易泄露且不可刷新

授权码模式

授权码模式适用于有自己的服务器的应用,它是一个一次性的临时凭证,用来换取 access_token 和 refresh_token。认证服务器提供了一个类似这样的接口:

https://www.funtl.com/exchange?code=&client_id=&client_secret=

需要传入 code、client_id 以及 client_secret。验证通过后,返回 access_token 和 refresh_token。一旦换取成功,code 立即作废,不能再使用第二次。流程图如下:
在这里插入图片描述
这个 code 的作用是保护 token 的安全性。上一节说到,简单模式下,token 是不安全的。这是因为在第 4 步当中直接把 token 返回给应用。而这一步容易被拦截、窃听。引入了 code 之后,即使攻击者能够窃取到 code,但是由于他无法获得应用保存在服务器的 client_secret,因此也无法通过 code 换取 token。而第 5 步,为什么不容易被拦截、窃听呢?这是因为,首先,这是一个从服务器到服务器的访问,黑客比较难捕捉到;其次,这个请求通常要求是 https 的实现。即使能窃听到数据包也无法解析出内容。

有了这个 code,token 的安全性大大提高。因此,oAuth2.0 鼓励使用这种方式进行授权,而简单模式则是在不得已情况下才会使用。

密码模式

密码模式中,用户向客户端提供自己的用户名和密码。客户端使用这些信息,向 “服务商提供商” 索要授权。在这种模式中,用户必须把自己的密码给客户端,但是客户端不得储存密码。这通常用在用户对客户端高度信任的情况下,比如客户端是操作系统的一部分。

一个典型的例子是同一个企业内部的不同产品要使用本企业的 oAuth2.0 体系。在有些情况下,产品希望能够定制化授权页面。由于是同个企业,不需要向用户展示“xxx将获取以下权限”等字样并询问用户的授权意向,而只需进行用户的身份认证即可。这个时候,由具体的产品团队开发定制化的授权界面,接收用户输入账号密码,并直接传递给鉴权服务器进行授权即可,做前后端分离登录就可以采用这种模式。
在这里插入图片描述
有一点需要特别注意的是,在第 2 步中,认证服务器需要对客户端的身份进行验证,确保是受信任的客户端。

客户端模式

如果信任关系再进一步,或者调用者是一个后端的模块,没有用户界面的时候,可以使用客户端模式。鉴权服务器直接对客户端进行身份验证,验证通过后,返回 token。
在这里插入图片描述

HelloWord

这里以授权模式为例,搭建一个完整的demo。
在这个demo中主要包括如下服务:

  • 第三方应用
  • 授权服务器
  • 资源服务器
  • 用户

用表格值整理如下:

项目 端口 备注
auth-server 8080 授权服务器
user-server 8081 资源服务器
client-app 8082 第三方应用

案例中我们常见授权模式登陆中,涉及到的各个角色,这里都自己提供、自己测试,这样可以最大限度的了解Oauth2的工作原理。

搭建各个服务之前,先创建一个Maven父工程,里面什么都不用加,我们在父工程中搭建子模块。

搭建授权服务器

创建一个名为auth-server的springboot项目。
引入依赖:

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

提供一个Spring Security 的基本配置

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("admin")
                .password(new BCryptPasswordEncoder().encode("123"))
                .roles("admin")
                .and()
                .withUser("pikachues")
                .password(new BCryptPasswordEncoder().encode("123"))
                .roles("user");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
    	// 开启csrf 允许表单登录
        http.csrf().disable().formLogin();
    }
}

这段配置的目的,实际上就是配置用户,这里为了方便简洁,直接将用户信息存到内存中。例如你想用微信登录第三方网站,在这个过程中,你得先登录微信,登录微信就要你的用户名/密码信息,那么我们在这里配置的,其实就是用户的用户名/密码/角色信息。

基本用户信息配置完成后,接下来配置授权服务器:

@Configuration
public class AccessTokenConfig {
    /**
     * 你生成的token要往哪里存,可以存放在redis中,也可以存放在内存中
     * 这里存放于内存中
     * @return
     */
    @Bean
    TokenStore tokenStore(){
        return new InMemoryTokenStore();
    }
}


@Configuration
@EnableAuthorizationServer  //开启授权服务器的自动化配置
public class AuthorizationServer extends AuthorizationServerConfigurerAdapter  {

    @Autowired
    TokenStore tokenStore;

    @Autowired
    ClientDetailsService clientDetailsService;

    /**
     * 对授权服务器进一步配置
     * 主要用来配置 Token 的一些基本信息,例如 Token 是否支持刷新、Token 的存储位置、Token 的有效期以及刷新 Token 的有效期等等
     * @return
     */
    @Bean
    AuthorizationServerTokenServices tokenServices(){
        DefaultTokenServices services = new DefaultTokenServices();
        services.setClientDetailsService(clientDetailsService);
        services.setSupportRefreshToken(true);
        services.setTokenStore(tokenStore);
        services.setAccessTokenValiditySeconds(60*60*2);
        services.setRefreshTokenValiditySeconds(60*60*24*3);
        return services;
    }

    /**
     * 用来配置令牌端点的完全约束,也就是这个端点谁能访问,谁不能访问
     * checkTokenAccess 是指一个 Token 校验的端点,这里设置可以直接访问
     * (在后面,当资源服务器收到 Token 之后,需要去校验 Token 的合法性,就会访问这个端点)。
     * @param security
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.checkTokenAccess("permitAll")
                .allowFormAuthenticationForClients();
    }

    /**
     * ClientDetailsServiceConfigurer用来配置客户端详细信息
     * 这里配置客户端校验(客户端信息可以存在数据库中)
     * @param clients
     * @throws Exception
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("pikachues")
                .secret(new BCryptPasswordEncoder().encode("123"))
                .resourceIds("res1")  // 客户端id
                .authorizedGrantTypes("authorization_code","refresh_token")  //授权类型,四种类型
                .scopes("all")  // 授权范围
                .redirectUris("http://localhost:8083/index.html");  //用户登录成功/失败后调转的地址
    }

    /**
     * AuthorizationServerEndpointsConfigurer 这里用来配置令牌的访问端点和令牌服务。
     * authorizationCodeServices 用来配置授权码的存储
     * tokenServices 用来配置令牌的存储 即 access_token 的存储位置
     * 授权码是用来获取令牌的,使用一次就失效,令牌则是用来获取资源的,
     * @param endpoints
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authorizationCodeServices(authorizationCodeServices())
                .tokenServices(tokenServices());
    }

    @Bean
    AuthorizationCodeServices authorizationCodeServices() {
        return new InMemoryAuthorizationCodeServices();
    }
}

资源服务器搭建

接下来我们搭建一个资源服务器。大家网上看到的例子,资源服务器大多都是和授权服务器放在一起的,如果项目比较小的话,这样做是没问题的,但是如果是一个大项目,这种做法就不合适了。

资源服务器就是用来存放用户的资源,例如你在微信上的图像、openid 等信息,用户从授权服务器上拿到 access_token 之后,接下来就可以通过 access_token 来资源服务器请求数据。

新建一个名为 user-server的springboot项目作为资源服务器。
依赖:

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

创建一个ResourceServerConfig类作为资源服务器配置:

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

    /**
     * 授权服务器跟资源服务器是分开的所以要配这个
     * setCheckTokenEndpointUrl 为token的校验地址
     * @return
     */
    @Bean
    RemoteTokenServices tokenServices(){
        RemoteTokenServices services = new RemoteTokenServices();
        services.setCheckTokenEndpointUrl("http://localhost:8080/oauth/check_token");
        services.setClientId("pikachues");
        services.setClientSecret("123");
        return services;
    }


    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.resourceId("res1").tokenServices(tokenServices());
    }

    /**
     * 配置资源拦截规则
     * @param http
     * @throws Exception
     */
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/admin/**").hasRole("admin")
                .anyRequest().authenticated();
    }

}

接下来提供两个用于测试的接口:

@RestController
public class HelloController {


    @GetMapping("/hello")
    public String hello() {
        return "hello";
    }
    @GetMapping("/admin/hello")
    public String admin() {
        return "admin";
    }
}

第三方应用搭建

创建一个名为alient-app的springboot项目,并引入如下依赖:

 <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

在 resources/templates 目录下,创建 index.html ,内容如下:

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>pikachues</title>
</head>
<body>
你好,pikachues!

<!--
 client_id 客户端id
 response_type 表示相应类型,这里是code表示相应一个授权码
 scope 表示授权范围
 redirect_uri 表示授权成功后跳转的地址
 msg  是从资源服务器获取的数据
 -->
<a href="http://localhost:8080/oauth/authorize?client_id=pikachues&response_type=code&scope=all&redirect_uri=http://localhost:8083/index.html">第三方登录</a>
<h1 th:text="${msg}"></h1>

</body>
</html>

接下来定义一个HelloController用于测试:

@Controller
public class HelloController {
    @Autowired
    RestTemplate restTemplate;

    @GetMapping("/index.html")
    public String hello(String code, Model model) {
        if (code != null) {
            MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
            map.add("code", code);
            map.add("client_id", "pikachues");
            map.add("client_secret", "123");
            map.add("redirect_uri", "http://localhost:8083/index.html");
            map.add("grant_type", "authorization_code");
            // 根据code去请求access_token
            Map<String,String> resp = restTemplate.postForObject("http://localhost:8080/oauth/token", map, Map.class);
            String access_token = resp.get("access_token");
            System.out.println(access_token);
            HttpHeaders headers = new HttpHeaders();
            headers.add("Authorization", "Bearer " + access_token);
            HttpEntity<Object> httpEntity = new HttpEntity<>(headers);
            //携带token从资源服务器获取数据
            ResponseEntity<String> entity = restTemplate.exchange("http://localhost:8081/admin/hello", HttpMethod.GET, httpEntity, String.class);
            model.addAttribute("msg", entity.getBody());
        }
        return "index";
    }
}

测试

首先访问http://localhost:8082/index.html,页面如下:
在这里插入图片描述
上面的页面就相当于如下这个页面。
在这里插入图片描述
点击第三方登录跳转到如下页面:
在这里插入图片描述
上面这个页面相当于如下页面:
在这里插入图片描述
在第三方登录页面中输入用户名:admin,密码:123点击登录跳转到如下页面
在这里插入图片描述
在这个页面中,我们可以看到一个提示,询问是否授权 pikachues这个用户去访问被保护的资源,我们选择 approve(批准),然后点击下方的 Authorize 按钮,点完之后,页面会自动跳转回我的第三方应用中:
在这里插入图片描述
大家注意,这个时候地址栏多了一个 code 参数,这就是授权服务器给出的授权码,拿着这个授权码,我们就可以去请求 access_token,授权码使用一次就会失效。

同时大家注意到页面多了一个 admin,这个 admin 就是从资源服务器请求到的数据。

当然,我们在授权服务器中配置了两个用户,大家也可以尝试用pikachues/123 这个用户去登录,因为这个用户不具备 admin 角色,所以使用这个用户将无法获取到 admin 这个字符串,报错信息如下:
在这里插入图片描述

Oauth2通用接口

以我们上面举例的授权模式为例,启动auth-server之后,在Idea可以看到暴露出来的接口:
在这里插入图片描述
接口含义如下表:

  • /oauth/authorize:授权端点
  • /oauth/token:获取令牌端点,刷新令牌(refresh_token)同样也是这个接口
  • /oauth/confirm_access:用户确认授权提交端点(询问是否授权提交到的就是这个端点)
  • /oauth/error:授权出错端点
  • /oauth/check_token:检验access_token端点
  • /oauth/token_key 提供公钥的端点

项目地址

https://github.com/xiaoxiaoshou/Oauth2Demo

本文主要是为了记录学习,让自己更好的理解Oauth2

本文参考:
https://mp.weixin.qq.com/s/GXMQI59U6uzmS-C0WQ5iUw
https://www.funtl.com/zh/spring-security-oauth2/

猜你喜欢

转载自blog.csdn.net/qq_41262903/article/details/106300407