OAuth2.0从入门到实战(附github地址)

news2024/11/19 21:18:41

OAuth2.0

文章目录

  • OAuth2.0
    • OAuth2.0的含义与思想
      • [快递员的例子]([OAuth 2.0 的一个简单解释 - 阮一峰的网络日志 (ruanyifeng.com)](https://www.ruanyifeng.com/blog/2019/04/oauth_design.html))
      • 互联网的例子
      • 令牌与密码
    • OAuth2.0的四种授权方式
      • RFC 6749
      • 一、授权码(前后端分离)
      • 二、隐藏式(纯前端应用)
      • 三、密码式
      • 四、凭证式(命令行应用)
      • 令牌的使用
      • 更新令牌
    • OAuth2.0 客户端实例
      • 需求描述
      • 环境准备
      • 演示Demo
      • 查看Github在我们应用中的注册信息
      • 查看获取到的AccessToken
      • 通过AccessToken请求Github API
    • OAuth2.0 授权码实例
      • 搭建授权服务器
      • 搭建资源服务器
      • 第三方应用搭建
      • 案例分析与优化
        • 令牌的存储位置
        • 客户端信息存储
        • 第三方应用优化
    • OAuth2.0 单点登录实例
      • 认证与资源中心配置
      • 客户端服务创建
      • 单点登录测试

OAuth2.0的含义与思想

OAuth 是一个开放标准,该标准允许用户让第三方应用访问该用户在某一网站上存储的私密资源(如头像、照片、视频等),而在这个过程中无需将用户名和密码提供给第三方应用。实现这一功能是通过提供一个令牌(token),而不是用户名和密码来访问他们存放在特定服务提供者的数据。采用令牌(token)的方式可以让用户灵活的对第三方应用授权或者收回权限。

OAuth2 是 OAuth 协议的下一版本,但不向下兼容 OAuth 1.0。传统的 Web 开发登录认证一般都是基于 session 的,但是在前后端分离的架构中继续使用 session 就会有许多不便,因为移动端(Android、iOS、微信小程序等)要么不支持 cookie(微信小程序),要么使用非常不便,对于这些问题,使用 OAuth2 认证都能解决。

对于大家而言,我们在互联网应用中最常见的 OAuth2 应该就是各种第三方登录了,例如 QQ 授权登录、微信授权登录、微博授权登录、GitHub 授权登录等等。

[快递员的例子](OAuth 2.0 的一个简单解释 - 阮一峰的网络日志 (ruanyifeng.com))

我住在一个大型的居民小区。

img

小区有门禁系统。

img

进入的时候需要输入密码。

img

我经常网购和外卖,每天都有快递员来送货。我必须找到一个办法,让快递员通过门禁系统,进入小区。

img

如果我把自己的密码,告诉快递员,他就拥有了与我同样的权限,这样好像不太合适。万一我想取消他进入小区的权力,也很麻烦,我自己的密码也得跟着改了,还得通知其他的快递员。

有没有一种办法,让快递员能够自由进入小区,又不必知道小区居民的密码,而且他的唯一权限就是送货,其他需要密码的场合,他都没有权限?

于是,我设计了一套授权机制:

image-20230208094420835

  • 第一步,门禁系统的密码输入器下面,增加一个按钮,叫做**“获取授权”**。快递员需要首先按这个按钮,去申请授权。
  • 第二步,他按下按钮以后,屋主(也就是我)的手机就会跳出对话框:有人正在要求授权。系统还会显示该快递员的姓名、工号和所属的快递公司。
  • 我确认请求属实,就点击按钮,告诉门禁系统,我同意给予他进入小区的授权。
  • 第三步,门禁系统得到我的确认以后,向快递员显示一个进入小区的令牌(access token)。令牌就是类似密码的一串数字,只在短期内(比如七天)有效。
  • 第四步,快递员向门禁系统输入令牌,进入小区。

有人可能会问,为什么不是远程为快递员开门,而要为他单独生成一个令牌?这是因为快递员可能每天都会来送货,第二天他还可以复用这个令牌。另外,有的小区有多重门禁,快递员可以使用同一个令牌通过它们。

互联网的例子

例如我们有一个“云打印”的网站,可以将用户存储在Google的照片,打印出来。用户为了使用该服务,需要让“云打印”这个网站访问自己存储在Google的照片。

如何获得用户的授权呢?

传统方案是将用户将自己的Google账号密码告诉“云打印”网站。但是这种方案会有很多缺点:

  • 云打印存储Google密码,不安全
  • Google必须有密码登录的功能
  • 云打印拥有了获取用户Google资源的权力,但是用户无法限制云打印获得授权的范围和有效期
  • 用户只有修改密码,才可以收回赋予的权利,但是这样严重影响其它应用
  • 只要有一个第三方应用被破解,密码就会泄漏

OAuth就是为了解决上面这些问题而诞生的。

用OAuth的方案:云打印请求获取授权,用户同意给云打印授权,云打印使用上一步的授权码向Google的认证服务器申请令牌,然后云打印使用令牌向Google的资源服务器申请资源,Google的资源服务器确认令牌并开放资源。

简单说,OAuth 就是一种授权机制。数据的所有者告诉系统,同意授权第三方应用进入系统,获取这些数据。系统从而产生一个短期的进入令牌(token),用来代替密码,供第三方应用使用。

令牌与密码

令牌(token)与密码(password)的作用是一样的,都可以进入系统,但是有三点差异。

  1. 令牌是短期的,到期会自动失效,用户自己无法修改。密码一般长期有效,用户不修改,就不会发生变化。
  2. 令牌可以被数据所有者撤销,会立即失效。以上例而言,屋主可以随时取消快递员的令牌。密码一般不允许被他人撤销。
  3. 令牌有权限范围(scope),比如只能进小区的二号门。对于网络服务来说,只读令牌就比读写令牌更安全。密码一般是完整权限。

上面这些设计,保证了令牌既可以让第三方应用获得权限,同时又随时可控,不会危及系统安全。这就是 OAuth 2.0 的优点。

OAuth2.0的四种授权方式

用户如何给与第三方应用权限,从而第三方可以根据此授权获取令牌?

RFC 6749

OAuth 2.0 的标准是 RFC 6749 文件。该文件先解释了 OAuth 是什么。

OAuth 引入了一个授权层,用来分离两种不同的角色:客户端和资源所有者。…资源所有者同意以后,资源服务器可以向客户端颁发令牌。客户端通过令牌,去请求数据。

这段话的意思就是,**OAuth 的核心就是向第三方应用颁发令牌。**然后,RFC 6749 接着写道:

(由于互联网有多种场景,)本标准定义了获得令牌的四种授权方式(authorization grant )。

也就是说,**OAuth 2.0 规定了四种获得令牌的流程。你可以选择最适合自己的那一种,向第三方应用颁发令牌。**下面就是这四种授权方式。

  • 授权码(authorization-code)
  • 隐藏式(implicit)
  • 密码式(password):
  • 客户端凭证(client credentials)

注意,不管哪一种授权方式,第三方应用申请令牌之前,都必须先到系统备案,说明自己的身份,然后会拿到两个身份识别码:客户端 ID(client ID)和客户端密钥(client secret)。这是为了防止令牌被滥用,没有备案过的第三方应用,是不会拿到令牌的。

一、授权码(前后端分离)

授权码(authorization code)方式,指的是第三方应用先申请一个授权码,然后再用该码获取令牌。

这种方式是最常用的流程,安全性也最高,它适用于那些有后端的 Web 应用。授权码通过前端传送,令牌则是储存在后端,而且所有与资源服务器的通信都在后端完成。这样的前后端分离,可以避免令牌泄漏。

A网站要获取B网站的授权

1️⃣ 第一步,A 网站(云打印)提供一个链接,用户点击后就会跳转到 B (Google)网站,授权用户数据给 A 网站使用。下面就是 A 网站跳转 B 网站的一个示意链接。

https://b.com/oauth/authorize?
  response_type=code&
  client_id=CLIENT_ID&
  redirect_uri=CALLBACK_URL&
  scope=read

上面 URL 中,response_type参数表示要求返回授权码(code),client_id参数让 B 知道是谁在请求,redirect_uri参数是 B 接受或拒绝请求后的跳转网址,scope参数表示要求的授权范围(这里是只读)。

2️⃣ 第二步,用户跳转后,B 网站会要求用户登录,然后询问是否同意给予 A 网站授权。用户表示同意,这时 B 网站就会跳回redirect_uri参数指定的网址。跳转时,会传回一个授权码,就像下面这样。

https://a.com/callback?code=AUTHORIZATION_CODE

image-20230208103512124

3️⃣ 第三步,A 网站拿到授权码以后,就可以在后端,向 B 网站请求令牌。

https://b.com/oauth/token?
 client_id=CLIENT_ID&
 client_secret=CLIENT_SECRET&
 grant_type=authorization_code&
 code=AUTHORIZATION_CODE&
 redirect_uri=CALLBACK_URL

上面 URL 中,client_id参数和client_secret参数用来让 B 确认 A 的身份(client_secret参数是保密的,因此只能在后端发请求),grant_type参数的值是AUTHORIZATION_CODE,表示采用的授权方式是授权码,code参数是上一步拿到的授权码,redirect_uri参数是令牌颁发后的回调网址。

image-20230208103642105

4️⃣ 第四步,B 网站收到请求以后,就会颁发令牌。具体做法是向redirect_uri指定的网址,发送一段 JSON 数据。

{    
  "access_token":"ACCESS_TOKEN",
  "token_type":"bearer",
  "expires_in":2592000,
  "refresh_token":"REFRESH_TOKEN",
  "scope":"read",
  "uid":100101,
  "info":{...}
}

上面 JSON 数据中,access_token字段就是令牌,A 网站在后端拿到了。

image-20230208104023422

最后A网站就可以通过令牌来访问B网站的资源了。

二、隐藏式(纯前端应用)

有些 Web 应用是纯前端应用,没有后端。这时就不能用上面的方式了,必须将令牌储存在前端。RFC 6749 就规定了第二种方式,允许直接向前端颁发令牌。这种方式没有授权码这个中间步骤,所以称为(授权码)“隐藏式”(implicit)。

1️⃣ 第一步,A 网站提供一个链接,要求用户跳转到 B 网站,授权用户数据给 A 网站使用。

https://b.com/oauth/authorize?
  response_type=token&
  client_id=CLIENT_ID&
  redirect_uri=CALLBACK_URL&
  scope=read

上面 URL 中,response_type参数为token,表示要求直接返回令牌。

2️⃣ 第二步,用户跳转到 B 网站,登录后同意给予 A 网站授权。这时,B 网站就会跳回redirect_uri参数指定的跳转网址,并且把令牌作为 URL 参数,传给 A 网站。

https://a.com/callback#token=ACCESS_TOKEN

上面 URL 中,token参数就是令牌,A 网站因此直接在前端拿到令牌。

image-20230208104726747

这种方式把令牌直接传给前端,是很不安全的。因此,只能用于一些安全要求不高的场景,并且令牌的有效期必须非常短,通常就是会话期间(session)有效,浏览器关掉,令牌就失效了。

三、密码式

如果你高度信任某个应用,RFC 6749 也允许用户把用户名和密码,直接告诉该应用。该应用就使用你的密码,申请令牌,这种方式称为"密码式"(password)。

第一步,A 网站要求用户提供 B 网站的用户名和密码。拿到以后,A 就直接向 B 请求令牌。

https://oauth.b.com/token?
  grant_type=password&
  username=USERNAME&
  password=PASSWORD&
  client_id=CLIENT_ID

上面 URL 中,grant_type参数是授权方式,这里的password表示"密码式",usernamepassword是 B 的用户名和密码。

第二步,B 网站验证身份通过后,直接给出令牌。注意,这时不需要跳转,而是把令牌放在 JSON 数据里面,作为 HTTP 回应,A 因此拿到令牌。

这种方式需要用户给出自己的用户名/密码,显然风险很大,因此只适用于其他授权方式都无法采用的情况,而且必须是用户高度信任的应用。

四、凭证式(命令行应用)

最后一种方式是凭证式(client credentials),适用于没有前端的命令行应用,即在命令行下请求令牌。

第一步,A 应用在命令行向 B 发出请求。

https://oauth.b.com/token?
  grant_type=client_credentials&
  client_id=CLIENT_ID&
  client_secret=CLIENT_SECRET

上面 URL 中,grant_type参数等于client_credentials表示采用凭证式,client_idclient_secret用来让 B 确认 A 的身份。

第二步,B 网站验证通过以后,直接返回令牌。

这种方式给出的令牌,是针对第三方应用的,而不是针对用户的,即有可能多个用户共享同一个令牌。

令牌的使用

A 网站拿到令牌以后,就可以向 B 网站的 API 请求数据了。

此时,每个发到 API 的请求,都必须带有令牌。具体做法是在请求的头信息,加上一个Authorization字段,令牌就放在这个字段里面。

curl -H "Authorization: Bearer ACCESS_TOKEN" \
"https://api.b.com"

上面命令中,ACCESS_TOKEN就是拿到的令牌。

更新令牌

令牌的有效期到了,如果让用户重新走一遍上面的流程,再申请一个新的令牌,很可能体验不好,而且也没有必要。OAuth 2.0 允许用户自动更新令牌。

具体方法是,B 网站颁发令牌的时候,一次性颁发两个令牌,一个用于获取数据,另一个用于获取新的令牌(refresh token 字段)。令牌到期前,用户使用 refresh token 发一个请求,去更新令牌。

https://b.com/oauth/token?
  grant_type=refresh_token&
  client_id=CLIENT_ID&
  client_secret=CLIENT_SECRET&
  refresh_token=REFRESH_TOKEN

上面 URL 中,grant_type参数为refresh_token表示要求更新令牌,client_id参数和client_secret参数用于确认身份,refresh_token参数就是用于更新令牌的令牌。

B 网站验证通过以后,就会颁发新的令牌。

OAuth2.0 客户端实例

需求描述

我们这里将会演示将我们的应用作为一个OAuth2.0客户端来集成Github登录,并实现对Github资源的访问。

环境准备

1️⃣ 在Github注册一个应用,生成 client-idclient-secret

image-20230208134214597

2️⃣ SpringSecurity的集成,SpringSecurity 本身提供了 GOOGLE GITHUB FACEBOOK OKTAOAuth2.0 接入支持,具体源码在枚举类 CommonOAuth2Provider 中。

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

演示Demo

1️⃣首先将Github的Client-Id等信息配置到yml文件:

server:
  port: 8888
spring:
  security:
    oauth2:
      client:
        registration:
          github:
            client-id: XXXXXXXXXXXXXXX
            client-secret: XXXXXXXXXXXXXXXXXXXX

2️⃣ 提供一个home页面Controller

    @GetMapping(value = "/")
    public String index() {
        log.info(SecurityContextHolder.getContext().getAuthentication().toString());
        return "Welcome " + SecurityContextHolder.getContext().getAuthentication();
    }

3️⃣ 访问localhost:8888/login

image-20230208134534436

点击通过Github登录:

image-20230208134710995

我们授权登录后,页面会重定向到我们配置的home页面:

image-20230208134745748

借助 SpringSecurityOAuth2.0 的支持,我们几乎不用写什么代码就实现了 Github 登录集成。下面再通过几个例子来了解更多的细节。

查看Github在我们应用中的注册信息

    @GetMapping(value = "/user/reg")
    public String registration() {
        ClientRegistration githubRegistration = this.clientRegistrationRepository.findByRegistrationId("github");
        log.info(githubRegistration.toString());
        return githubRegistration.toString();
    }

访问之后会返回 registration 信息,其中包含了 clientIdclientSecretauthorizationGrantTyperedirectUriscopes 等。

image-20230208140056675

查看获取到的AccessToken

    @GetMapping(value = "/user/token")
    public OAuth2AccessToken accessToken(OAuth2AuthenticationToken authentication) {
        OAuth2AuthorizedClient authorizedClient = this.authorizedClientService.loadAuthorizedClient(
                authentication.getAuthorizedClientRegistrationId(), authentication.getName());
        OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
        return accessToken;
    }

请求接口我们可以获取到对应的token信息:

{
    "tokenValue":"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",
    "issuedAt":"2023-02-08T06:05:05.107Z",
    "expiresAt":"2023-02-08T06:05:06.107Z",
    "tokenType":{
        "value":"Bearer"
    },
    "scopes":[
        "read:user"
    ]
}

通过AccessToken请求Github API

定义抽象 API 绑定类,通过拦截器将获取到的 AccessToken 设置到后续请求头中,通过 RestTemplate 实现对 API 的请求:

资料: 用户 - GitHub 文档

1️⃣ 封装Api Binding 为RestTemplate绑定请求头

/**
 * @Description: 绑定请求头Authorization
 * @Author: Ze WANG
 **/
public abstract class ApiBinding {
    protected RestTemplate restTemplate;

    public ApiBinding(String accessToken) {
        this.restTemplate = new RestTemplate();
        if (accessToken != null) {
            this.restTemplate.getInterceptors().add(getBearerTokenInterceptor(accessToken));
        } else {
            this.restTemplate.getInterceptors().add(getNoTokenInterceptor());
        }
    }

    private ClientHttpRequestInterceptor getBearerTokenInterceptor(String accessToken) {
        return new ClientHttpRequestInterceptor() {
            @Override
            public ClientHttpResponse intercept(HttpRequest request, byte[] bytes, ClientHttpRequestExecution execution) throws IOException {
                request.getHeaders().add("Authorization", "Bearer " + accessToken);
                return execution.execute(request, bytes);
            }
        };
    }

    private ClientHttpRequestInterceptor getNoTokenInterceptor() {
        return new ClientHttpRequestInterceptor() {
            @Override
            public ClientHttpResponse intercept(HttpRequest request, byte[] bytes, ClientHttpRequestExecution execution) throws IOException {
                throw new IllegalStateException("Can't access the Github API without an access token");
            }
        };
    }
}
/**
 * @Description: Github请求
 * @Author: Ze WANG
 **/
public class Github extends ApiBinding {
    private static final String BASE_URL = "https://api.github.com";

    public Github(String accessToken) {
        super(accessToken);
    }
    public String getProfile() {
        return restTemplate.getForObject(BASE_URL + "/user", String.class);
    }
}

2️⃣ 封装获取accessToken的过程

/**
 * @Description: 封装获取accessToken的过程
 * @Author: Ze WANG
 **/
@Configuration
@Slf4j
public class SocialConfig {
    @Bean
    @RequestScope
    public Github github(OAuth2AuthorizedClientService clientService) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String accessToken = null;
        if (authentication.getClass().isAssignableFrom(OAuth2AuthenticationToken.class)) {
            OAuth2AuthenticationToken oauthToken = (OAuth2AuthenticationToken) authentication;
            String clientRegistrationId = oauthToken.getAuthorizedClientRegistrationId();
            if (clientRegistrationId.equals("github")) {
                OAuth2AuthorizedClient client = clientService.loadAuthorizedClient(clientRegistrationId, oauthToken.getName());
                if (client != null) {
                    accessToken = client.getAccessToken().getTokenValue();
                }
                log.info(accessToken);
            }
        }
        return new Github(accessToken);
    }
}

3️⃣ Controller

@GetMapping(value = "/user/info")
public String info() {
    String profile = github.getProfile();
    log.info(github.getProfile());
    return profile;
}

4️⃣ 测试请求

image-20230208143817414

OAuth2.0 授权码实例

上一章节我们仅仅是模拟了第三方应用如何通过OAuth2.0来实现Github的授权登录,这一章节,我们能将通过一个完整的Demo来梳理OAuth2.0的授权码模式。

在这个案例中,主要包括如下服务:

  • 第三方应用
  • 授权服务器
  • 资源服务器
  • 用户
项目端口备注
auth-server8081授权服务器
user-server8082资源服务器
client-app8083第三方应用

搭建授权服务器

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

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

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

1️⃣ 首先配置SpringSecurity的基础配置:这段配置的目的,实际上就是配置用户。

/**
 * @Description: SpringSecurity的基本配置
 * @Author: Ze WANG
 **/
@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("wangze")
                .password(new BCryptPasswordEncoder().encode("123"))
                .roles("user");
    }

    /**
     * 配置表单登录
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().formLogin();
    }
}

2️⃣ 配置授权服务器

首先我们提供了一个 TokenStore 的实例,这个是指你生成的 Token 要往哪里存储,我们可以存在 Redis 中,也可以存在内存中,也可以结合 JWT 等等,这里,我们就先把它存在内存中,所以提供一个 InMemoryTokenStore 的实例即可。

/**
 * @Description: Token存储位置
 * @Author: Ze WANG
 **/
@Configuration
public class AccessTokenConfig {
    @Bean
    TokenStore tokenStore() {
        return new InMemoryTokenStore();
    }
}
/**
 * @Description: 授权服务
 * @Author: Ze WANG
 **/
//@EnableAuthorizationServer 注解,表示开启授权服务器的自动化配置。
@EnableAuthorizationServer
@Configuration
public class AuthorizationServer extends AuthorizationServerConfigurerAdapter {
    @Autowired
    TokenStore tokenStore;
    @Autowired
    ClientDetailsService clientDetailsService;

    /**
     * 主要用来配置 Token 的一些基本信息,例如 Token 是否支持刷新、Token 的存储位置、Token 的有效期以及刷新 Token 的有效期等等。
     */
    @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;
    }


    /**
     * 用来配置令牌端点的安全约束,也就是这个端点谁能访问,谁不能访问。
     */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.checkTokenAccess("permitAll()")
                .allowFormAuthenticationForClients();
    }

    /**
     * 第三方应用(客户端)详细信息服务配置,此处类似与github上注册应用
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("wz-app")
                .secret(new BCryptPasswordEncoder().encode("123"))
                .resourceIds("res1")
                .authorizedGrantTypes("authorization_code","refresh_token")
                .scopes("all")
                .redirectUris("http://localhost:8083/index.html");
    }

    /**
     * 用来配置令牌的访问端点和令牌服务
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        //authorizationCodeServices用来配置授权码(Code)的存储,这里我们是存在在内存中
        endpoints.authorizationCodeServices(authorizationCodeServices())
                //tokenServices 用来配置令牌的存储,即 access_token 的存储位置,这里我们也先存储在内存中
                .tokenServices(tokenServices());
    }
    @Bean
    AuthorizationCodeServices authorizationCodeServices() {
        return new InMemoryAuthorizationCodeServices();
    }
}

搭建资源服务器

/**
 * @Description: 资源服务器配置
 * @Author: Ze WANG
 **/
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

    /**
     * RemoteTokenServices 中我们配置了 access_token 的校验地址、client_id、client_secret 这三个信息,
     */
    @Bean
    RemoteTokenServices tokenServices() {
        RemoteTokenServices services = new RemoteTokenServices();
        //Spring Security 默认校验地址
        services.setCheckTokenEndpointUrl("http://localhost:8081/oauth/check_token");
        services.setClientId("wz-app");
        services.setClientSecret("123");
        return services;
    }

    /**
     * 当用户来资源服务器请求资源时,会携带上一个 access_token,通过这里的配置,就能够校验出 token 是否正确等。
     */
    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.resourceId("res1").tokenServices(tokenServices());
    }

    /**
     * 配置一下资源的拦截规则,admin的资源需要有admin的权限
     */
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/admin/**").hasRole("admin")
                .anyRequest().authenticated();
    }
}

资源

/**
 * @Description: 测试接口
 * @Author: Ze WANG
 **/
@RestController
public class ResController {

    @GetMapping("/res")
    public String hello() {
        return "====普通资源====";
    }
    @GetMapping("/admin/res")
    public String admin() {
        return "====admin资源====";
    }
}

第三方应用搭建

为了简单的演示,此处使用Thymeleaf来写少量简单的前端代码:在resources/template目录下,创建index.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>wz-app</title>
</head>
<body>
<h1>Hello!  WZ-APP</h1>

<hr>
登录:
<a href="http://localhost:8081/oauth/authorize?client_id=wz-app&response_type=code&scope=all&redirect_uri=http://localhost:8083/index.html">第三方登录</a>
<br>
<h3 th:text="${token}"></h3>
<h1 th:text="${res}"></h1>


</body>
</html>

然后提供一个测试Controller:

@Controller
public class HelloController {

    @Autowired
    private 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", "wz-app");
            map.add("client_secret", "123");
            map.add("redirect_uri", "http://localhost:8083/index.html");
            map.add("grant_type", "authorization_code");
            //获取令牌
            Map<String,String> resp = restTemplate.postForObject("http://localhost:8081/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);
            ResponseEntity<String> entity = restTemplate.exchange("http://localhost:8082/admin/res", HttpMethod.GET, httpEntity, String.class);
            model.addAttribute("token","令牌:"+access_token);
            model.addAttribute("res", "资源"+entity.getBody());
        }
        return "index";
    }
}

下面我们先分析一下预期的流程,然后再来测试看是否符合预期:

image-20230209094815416

下面通过测试来走一遍流程:

首先进入第三方应用首页:

image-20230209094401712

点击第三方登录:通过admin账号 登录

image-20230209094426290

授权:

image-20230209094506279

授权后我们可以看到会url上会带有code,并且获得了令牌和资源:

image-20230209094615888

这里仅仅是一个简单的例子,为了方便熟悉OAuth2.0的授权码模式全流程。access_token通常会通过一个定时任务来维护,不需要每次请求页面都去获取,定期更新即可。

案例分析与优化

通过上边的例子,我们发现我们大部分的存储都是在内存中做的。我们可以从以下几个方面进行简单的优化:

  • 令牌的存储位置
  • 客户端信息入库
  • 第三方应用优化

令牌的存储位置

在我们配置授权码的时候,将授权码和令牌都存储在了内存中,我们可以看看TokenStroe的类图:

image-20230209095825813

  1. InMemoryTokenStore,这是我们之前使用的,也是系统默认的,就是将 access_token 存到内存中,单机应用这个没有问题,但是在分布式环境下不推荐。
  2. JdbcTokenStore,看名字就知道,这种方式令牌会被保存到数据中,这样就可以方便的和其他应用共享令牌信息。
  3. JwtTokenStore,这个其实不是存储,因为使用了 jwt 之后,在生成的 jwt 中就有用户的所有信息,服务端不需要保存,这也是无状态登录。
  4. RedisTokenStore,这个很明显就是将 access_token 存到 redis 中。
  5. JwkTokenStore,将 access_token 保存到 JSON Web Key。

虽然这里支持的方案比较多,但是我们常用的实际上主要是两个,RedisTokenStore 和 JwtTokenStore

客户端信息存储

客户端也就是第三方app的信息,之前我们也是直接写在内存中,同样我们可以通过ClientDetailsService的类图发现其提供的存储方法:

image-20230209100423314

除了内存的方式,只有额外数据库的存储的方式,通过源码可以分析出数据库的结构:

DROP TABLE IF EXISTS `oauth_client_details`;
CREATE TABLE `oauth_client_details` (
  `client_id` varchar(48) NOT NULL,
  `resource_ids` varchar(256) DEFAULT NULL,
  `client_secret` varchar(256) DEFAULT NULL,
  `scope` varchar(256) DEFAULT NULL,
  `authorized_grant_types` varchar(256) DEFAULT NULL,
  `web_server_redirect_uri` varchar(256) DEFAULT NULL,
  `authorities` varchar(256) DEFAULT NULL,
  `access_token_validity` int(11) DEFAULT NULL,
  `refresh_token_validity` int(11) DEFAULT NULL,
  `additional_information` varchar(4096) DEFAULT NULL,
  `autoapprove` varchar(256) DEFAULT NULL,
  PRIMARY KEY (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

第三方应用优化

我们上面分析了,demo中的令牌不能自动续期,我们可以通过一个TokenTask来管理Token:

    @GetMapping("/index.html")
    public String res(String code, Model model) {
        model.addAttribute("res", tokenTask.getData(code));
        return "index";
    }
@Component
@Slf4j
public class TokenTask {
    @Autowired
    RestTemplate restTemplate;
    public String access_token = "";
    public String refresh_token = "";

    public String getData(String code) {
        if ("".equals(access_token) && code != null) {
            MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
            map.add("code", code);
            map.add("client_id", "wz-app");
            map.add("client_secret", "123");
            map.add("redirect_uri", "http://localhost:8083/index.html");
            map.add("grant_type", "authorization_code");
            Map<String, String> resp = restTemplate.postForObject("http://localhost:8081/oauth/token", map, Map.class);
            access_token = resp.get("access_token");
            refresh_token = resp.get("refresh_token");
            return loadDataFromResServer();
        } else {
            return loadDataFromResServer();
        }
    }

    private String loadDataFromResServer() {
        try {
            HttpHeaders headers = new HttpHeaders();
            headers.add("Authorization", "Bearer " + access_token);
            HttpEntity<Object> httpEntity = new HttpEntity<>(headers);
            ResponseEntity<String> entity = restTemplate.exchange("http://localhost:8082/admin/res", HttpMethod.GET, httpEntity, String.class);
            log.info("资源数据为=={}",entity.getBody());
            return entity.getBody();
        } catch (RestClientException e) {
            return "未加载";
        }
    }

    @Scheduled(cron = "0 55 0/1 * * ?")
    public void tokenTask() {
        MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
        map.add("client_id", "wz-app");
        map.add("client_secret", "123");
        map.add("refresh_token", refresh_token);
        map.add("grant_type", "refresh_token");
        Map<String, String> resp = restTemplate.postForObject("http://localhost:8081/oauth/token", map, Map.class);
        log.debug("定时任务获取的data=={}",resp);
        access_token = resp.get("access_token");
        refresh_token = resp.get("refresh_token");
    }
}

OAuth2.0 单点登录实例

单点登录是我们在分布式系统中很常见的一个需求。

分布式系统由多个不同的子系统组成,而我们在使用系统的时候,只需要登录一次即可,这样其他系统都认为用户已经登录了,不用再去登录。

下面的例子通过 Spring Boot+OAuth2 做单点登录,利用 @EnableOAuth2Sso 注解快速实现单点登录功能。

我们要实现单点登录,需要我们再提供多个客户端,并且当这个客户端登录成功后,其它客户端不需要再登录。

认证与资源服务依旧采用上个例子中的服务。我们再来开发两个客户端来实现单点登录的效果。

项目端口备注
auth-res-server8086鉴权与资源中心
client18084第三方应用client1
client28085第三方应用client2

认证与资源中心配置

这里为了简便,采用认证与资源使用一个服务的方式。

依赖:

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

项目创建成功之后,这个模块由于要扮演授权服务器+资源服务器的角色,所以我们先在这个项目的启动类上添加 @EnableResourceServer 注解,表示这是一个资源服务器:

@SpringBootApplication
@EnableResourceServer
public class AuthResServerApplication {

    public static void main(String[] args) {
        SpringApplication.run(AuthResServerApplication.class, args);
    }

}

接下来我们进行授权服务器的配置,由于资源服务器和授权服务器合并在一起,因此授权服务器的配置要省事很多:

@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {
    @Autowired
    PasswordEncoder passwordEncoder;

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("sso")
                .secret(passwordEncoder.encode("123"))
                .autoApprove(true)//自动授权
                .redirectUris("http://localhost:8084/login", "http://localhost:8085/login")
                .scopes("user")
                .accessTokenValiditySeconds(7200)
                .authorizedGrantTypes("authorization_code");

    }
}

接下来我们再来配置 Spring Security:

@Configuration
@Order(1)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/login.html", "/css/**", "/js/**", "/images/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.requestMatchers()
                .antMatchers("/login")
                .antMatchers("/oauth/authorize")
                .and()
                .authorizeRequests().anyRequest().authenticated()
                .and()
                .formLogin()
                .loginPage("/login.html") //自定义的一个登录页面
                .loginProcessingUrl("/login")
                .permitAll()
                .and()
                .csrf().disable();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("wz")
                .password(passwordEncoder().encode("123"))
                .roles("admin");
    }
}

添加一个暴露用户信息的资源接口:

 @GetMapping("/user")
    public Principal getCurrentUser(Principal principal) {
        return principal;
    }

客户端服务创建

我们需要创建两个客户端,名字分别为client1client2。都添加Spring Security + Oauth2的依赖。

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

然后我们配置以下客户端的Spring Security

/**
 * @Description: SecurityConfig,client中的所有接口都需要认证之后才能访问
 * @Author: Ze WANG
 **/
@Configuration
@EnableOAuth2Sso //开启单点登录的功能
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest().authenticated().and().csrf().disable();
    }
}

提供测试接口:

/**
 * @Description: UserController,返回当前登录的用户的姓名和角色信息
 * @Author: Ze WANG
 **/
@RestController
public class UserController {
    @GetMapping("/user")
    public String user() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        return authentication.getName() + Arrays.toString(authentication.getAuthorities().toArray());
    }
}

配置OAuth2的相关信息

# client-secret
security.oauth2.client.client-secret=123
# client-id
security.oauth2.client.client-id=sso
# get user authorize
security.oauth2.client.user-authorization-uri=http://localhost:8086/oauth/authorize
# get token
security.oauth2.client.access-token-uri=http://localhost:8086/oauth/token
# user info
security.oauth2.resource.user-info-uri=http://localhost:8086/user

#port
server.port=8084

#cookie-name
server.servlet.session.cookie.name=client1_cookie

单点登录测试

  1. 直接访问client1/user接口,会要求我们登录,重定向到client1/login,由于我们配置了@EnableOAuth2Sso所以这个操作会被拦截下来,根据我们的配置自动发起请求去获取授权码

    image-20230209153221921

    image-20230209153259008

  2. 跳转到鉴权中心的/oauth/authorize,需要先登录,登录之后,授权,授权后获得授权码。

    image-20230209153355166

    image-20230209153431092

  3. 获取到授权码之后,这个时候会重定向到我们 client1 的 login 页面,但是实际上我们的 client1 其实是没有登录页面的,所以这个操作依然会被拦截,此时拦截到的地址包含有授权码,拿着授权码,在 OAuth2ClientAuthenticationProcessingFilter 类中向鉴权中心发起请求,就能拿到 access_token 了。

  4. 拿到 access_token 之后,接下来在向我们配置的 user-info-uri 地址发送请求,获取登录用户信息。

    image-20230209153500145

  5. 这时候在请求client2的/user接口,不需要手动再次登录。

    image-20230209153739575

github地址:Oauth2-sso-demo

参考:

[1].OAuth 2.0 的一个简单解释 - 阮一峰的网络日志 (ruanyifeng.com)

[2].OAuth 2.0 的四种方式 - 阮一峰的网络日志 (ruanyifeng.com)

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/371158.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Vue3电商项目实战-商品详情模块6【17-商品详情-标签页组件、18-商品详情-热榜组件、19-商品详情-详情组件、20-商品详情-注意事项组件】

文章目录17-商品详情-标签页组件18-商品详情-热榜组件19-商品详情-详情组件20-商品详情-注意事项组件17-商品详情-标签页组件 目的&#xff1a;实现商品详情组件和商品评价组件的切换 大致步骤&#xff1a; 完成基础的tab的导航布局完成tab标签页的切换样式效果使用动态组件完…

【设计模式】 策略模式介绍及C代码实现

【设计模式】 策略模式介绍及C代码实现 背景 在软件构建过程中&#xff0c;某些对象使用的算法可能多种多样&#xff0c;经常改变&#xff0c;如果将这些算法都编码到对象中&#xff0c;将会使对象变得异常复杂&#xff0c;而且有时候支持不使用的算法也是一个性能负担。 如何…

go单元测试

接着上一篇中的go module创建项目calc为例&#xff0c;在simplemath包中&#xff0c;是使用在命令行中使用交互式的方式进行测试&#xff0c;现在可以为这几个函数实现单元测试&#xff0c; go test&#xff0c;这个测试工具来自于 Go 官方的 gc 工具链。 运行 go test 命令将执…

JVM本地方法接口和本地方法栈

1、本地方法概述简单地讲&#xff0c;一个Native Method是一个Java调用非Java代码的接囗。一个Native Method是的实现由非Java语言实现&#xff0c;比如C。这个特征并非Java所特有&#xff0c;很多其它的编程语言都有这一机制&#xff0c;比如在C中&#xff0c;你可以用extern …

openpnp - 零碎记录

文章目录openpnp - 零碎记录概述笔记配置文件保存无效ENDopenpnp - 零碎记录 概述 这段时间, 正在配置校准手头的openpnp设备, 用的官网最新的openpnp2.0. 由于openpnp的bug和自己的不细致, 导致多次校准失败. 现在从头校准时, 每进行一步, 就保存一下配置文件, 如果最终发现…

MySQL_主从复制读写分离

主从复制 概述 主从复制是指将主数据库的DDL和DML操作通过二进制日志传到从库服务器中&#xff0c;然后在从库上对这些日志重新执行&#xff08;也叫重做&#xff09;&#xff0c;从而使得从库和主库的数据保持同步。 MySQL支持一台主库同时向多台从库进行复制&#xff0c;从…

leetcode 31~40 学习经历

leetcode 31~40 学习经历31. 下一个排列32. 最长有效括号33. 搜索旋转排序数组34. 在排序数组中查找元素的第一个和最后一个位置35. 搜索插入位置36. 有效的数独37. 解数独38. 外观数列39. 组合总和40. 组合总和 II小结31. 下一个排列 整数数组的一个 排列 就是将其所有成员以序…

3.JVM内存分配机制详解【2023】

redis跳表 内容概要 内存分配 1.类加载检查 &#x1f60a;虚拟机遇到一条new指令时&#xff0c;首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用&#xff0c;并且检查这个 符号引用代表的类是否已被加载、解析和初始化过。如果没有&#xff0c;那必须先执…

MySQL/Oracle获取当前时间几天/分钟前的时间

获取当前时间 要想获取当前时间几天/分钟前的时间&#xff0c;首先要知道怎么获取当前时间&#xff1b; 对于MySQL和Oracle获取当前时间的方法是不一样的&#xff1b; MySQL&#xff1a; select NOW(); 示例&#xff1a; Oracle&#xff1a; select sysdate from dual; 示…

西北工业大学2020-2021学年大物(I)下期末试题选填解析

2 位移电流。磁效应服从安培环路&#xff0c;热效应不服从焦耳-楞次定律。注意&#xff0c;它是变化的电场而非磁场产生。3 又考恒定磁场中安培环路定理。4感生电场5 麦克斯韦速率分布函数。6 相同的高温热源和低温热源之间的一切可逆热机的工作效率相等&#xff0c;无论工质如…

java 内部类的四种“写法”

基本介绍语法格式分类成员内部类静态内部类局部内部类匿名内部类&#xff08;&#x1f402;&#x1f58a;&#xff09;一、基本介绍 : 1.概述当一个类的内部又完整地嵌套了另一个类时&#xff0c;被嵌套于内部的“内核”我们称之为“内部类”(inner class)&#xff1b;而包含该…

Airbyte,数据集成的未来

Gartner 曾预计&#xff0c;到 2025 年&#xff0c;80% 寻求扩展数字业务的组织将失败。因为他们没有采用现代方法来进行数据和分析治理。数据生态是基础架构生态的最重要一环&#xff0c;数据的处理分发与计算&#xff0c;从始至终贯穿了整个数据流通生态。自从数据集中在数据…

3. Unity之三维模型

1. 网格 Mesh 三维物体模型在unity中一般称为mesh&#xff0c;即网格数据&#xff0c;模型一般使用专用的建模软件设计&#xff0c;将mesh文件导入到unity中进行使用&#xff0c;一般mesh中保存的是三维模型的面和顶点数据。在unity中通过下图方法进行调整&#xff0c;其中&am…

MakeFile教程

前言 当我们需要编译一个比较大的项目时&#xff0c;编译命令会变得越来越复杂&#xff0c;需要编译的文件越来越多。其 次就是项目中并不是每一次编译都需要把所有文件都重新编译&#xff0c;比如没有被修改过的文件则不需要重 新编译。工程管理器就帮助我们来优化这两个问题…

Elasticsearch7.8.0版本进阶——IK中文分词器

目录一、ES 的默认分词器测试示例二、IK 中文分词器2.1、IK 中文分词器下载地址2.2、ES 引入IK 中文分词器2.3、IK 中文分词器测试示例三、ES 扩展词汇测试示例一、ES 的默认分词器测试示例 通过 Postman 发送 GET 请求查询分词效果&#xff0c;在消息体里&#xff0c;指定要分…

python社团 培训记录(自2023年2月24日始)

在单位开设了Python社团&#xff0c;在此记录上课的有关情况&#xff1a; 课程概述&#xff1a;本社团主要针对五、六年级&#xff0c;初始招生&#xff08;上课前&#xff09;28人&#xff08;五、六年级各14人&#xff09;&#xff0c;后&#xff08;上课时&#xff09;人员…

一文让你彻底理解Linux内核调度器进程优先级

一、前言 本文主要描述的是进程优先级这个概念。从用户空间来看&#xff0c;进程优先级就是nice value和scheduling priority&#xff0c;对应到内核&#xff0c;有静态优先级、realtime优先级、归一化优先级和动态优先级等概念。我们希望能在第二章将这些相关的概念描述清楚。…

优秀的网络安全工程师应该有哪些能力?

网络安全工程师是一个各行各业都需要的职业&#xff0c;工作内容属性决定了它不会只在某一方面专精&#xff0c;需要掌握网络维护、设计、部署、运维、网络安全等技能。目前稍有经验的薪资在10K-30K之间&#xff0c;全国的网络安全工程师还处于一个供不应求的状态&#xff0c;因…

Linux | 项目自动化构建工具 - make/Makefile

make / Makefile一、前言二、make/Makefile背景介绍1、Makefile是干什么的&#xff1f;2、make又是什么&#xff1f;三、demo实现【见见猪跑&#x1f416;】三、依赖关系与依赖方法1、概念理清2、感性理解【父与子】3、深层理解【程序的翻译环境 栈的原理】四、多学一招&#…

网络编程(Java)

网络协议通信 IP和端口号 要想使计算机能够通信&#xff0c;必需为每台计算机指定一个标识号&#xff0c;通过这个标识号指定接受数据的计算机或者发送数据的计算机。一般的&#xff0c;IP地址就是一个计算机的标识号&#xff0c;它可以唯一标识一台计算机。 IP地址由两部分组…