SpringSecurity(二十五):OAuth2

OAuth是一个开放标准,该标准允许用户让第三方应用访问该用户在某一个网站上存储的私密资源(如头像,照片,视频等),并且在这个过程中无需将用户名和密码提供给第三方应用。

对于用户而言,我们在互联网应用中最常见OAuth应用就是各种第三方登录,例如QQ授权登陆,微信授权登录,微博授权登陆等等

四种授权模式

OAuth2一共支持四种不同的授权模式:
(1)授权码模式:常见的第三方平台登录功能基本都是使用这种模式。
(2)简化模式:简化模式是不需要客户端服务器参与,直接在浏览器中向授权服务器申请令牌(token),一般如果网站是纯静态页面则可以采用这种方式。
(3)密码模式:密码模式是用户把用户名密码直接告诉客户端,客户端使用这些信息向授权服务器申请令牌(token)。这需要用户对客户端高度信任,例如客户端应用和服务提供商就是同一家公司,我们自己做前后端分离登录就可以采用这种模式。
(4)客户端模式:客户端模式是指客户端使用自己的名义而不是用户的名义向服务提供者申请授权,严格来说,客户端模式并不能算作 OAuth 协议要解决的问题的一种解决方案,但是,对于开发者而言,在一些前后端分离应用或者为移动端提供的认证授权服务器上使用这种模式还是非常方便的。

授权码模式

授权码模式是最安全并且使用最广泛的一种模式。

在授权码模式中,我们分授权服务器和资源服务器,授权服务器用来派发 Token,拿着 Token 则可以去资源服务器获取资源,这两个服务器可以分开,也可以合并。

以松哥的 www.javaboy.org 为例,假如我要引入微信登录功能,那么我的流程可能是这样:

1.首先,我会在我的 www.javaboy.org 这个网页上放一个超链接(我的网站相当于是第三方应用),用户 A (服务方的用户,例如微信用户)点击这个超链接就会去请求授权服务器(微信的授权服务器),用户点击的过程其实也就是我跟用户要授权的过程,这就是上图中的 1、2 步。

2.接下来的第三步,就是用户点击了超链接之后,像授权服务器发送请求,然后进行登录,一般来说,我放在 www.javaboy.org 网页上的超链接可能有如下参数:

https://wx.qq.com/oauth/authorize?response_type=code&client_id=javaboy&redirect_uri=www.javaboy.org&scope=all&state=123

这里边有好几个参数,在后面的代码中我们都会用到,这里先和大家简单解释一下:
(1)response_type 表示授权类型,使用授权码模式的时候这里固定为 code,表示要求返回授权码(将来拿着这个授权码去获取 access_token)。

(2)client_id 表示客户端 id,也就是我应用的 id。有的小伙伴对这个不好理解,我说一下,如果我想让我的 www.javaboy.org 接入微信登录功能,我肯定得去微信开放平台注册,去填入我自己应用的基本信息等等,弄完之后,微信会给我一个 APPID,也就是我这里的 client_id,所以,从这里可以看出,授权服务器在校验的时候,会做两件事:1.校验客户端的身份;2.校验用户身份。

(3)redirect_uri 表示用户登录在成功/失败后,跳转的地址(成功登录微信后,跳转到 www.javaboy.org 中的哪个页面),跳转的时候,还会携带上一个授权码参数。

(4)scope 表示授权范围,即 www.javaboy.org 这个网站拿着用户的 token 都能干啥(一般来说就是获取用户非敏感的基本信息)。

(5)state:如果一开始提供了state, 授权服务器会原封不动的返回该参数,通过该参数的校验,可以防止CSRF攻击

3.接下来第四步,授权服务器重定向到redirect_uri中,拿着第三步获取到的 code 以及自己的 client_id 和 client_secret 以及其他一些信息去授权服务器请求令牌,微信的授权服务器在校验过这些数据之后,就会发送一个令牌回来。这个过程一般是在后端完成的,而不是利用 js 去完成。接下来拿着这个 token,我们就可以去请求用户信息了。

一般情况下我们认为授权码模式是四种模式中最安全的一种模式,因为这种模式我们的 access_token 不用经过浏览器或者移动端 App,是直接从我们的后台发送到授权服务器上,这样就很大程度减少了 access_token 泄漏的风险。

简化模式

现在的程序员搭建博客网站都流行静态网站,这类技术栈有一个共同的特点就是没有后端,开发者不需要讲精力放在维护后端网站上,只需要专注创作即可。

对于这种情况,如果我想接入微信登录该怎么办呢?这就用到了我们说的简化模式。

我们来看下简化模式的流程图:

这个流程是这样:
1.在我 www.javaboy.org 网站上有一个微信登录的超链接,这个超链接类似下面这样:

https://wx.qq.com/oauth/authorize?response_type=token&client_id=javaboy&redirect_uri=www.javaboy.org&scope=all

这里的参数和前面授权码模式的基本相同,只有 response_type 的值不一样,这里是 token,表示要求授权服务器直接返回 access_token。

2.用户点击我这个超链接之后,就会跳转到微信登录页面,然后用户进行登录。

3.用户登录成功后,微信会自动重定向到 redirect_uri 参数指定的跳转网址,同时携带上 access_token,这样用户在前端就获取到 access_token 了。

简化模式的弊端很明显,因为没有后端,所以非常不安全,除非你对安全性要求不高,否则不建议使用。

密码模式

密码模式在 Spring Cloud 项目中有着非常广泛的应用。

密码模式有一个前提就是你高度信任第三方应用,微服务中有一个特殊的场景,就是服务之间的调用,用密码模式做鉴权是非常恰当不过的了。这个以后再细说。

我们来看下密码模式的流程:

密码式的流程比较简单:
1.首先 www.javaboy.org 会发送一个 post 请求,类似下面这样的:

https://wx.qq.com/oauth/authorize?response_type=password&client_id=javaboy&username=江南一点雨&password=123

这里的参数和前面授权码模式的略有差异,response_type 的值不一样,这里是 password,表示密码式,另外多了用户名/密码参数,没有重定向的 redirect_uri ,因为这里不需要重定向。

2.微信校验过用户名/密码之后,直接在 HTTP 响应中把 access_token 返回给客户端。

客户端模式

有的应用可能没有前端页面,就是一个后台

我们来看一个客户端模式的流程图

这个步骤也很简单,就两步:

1.客户端发送一个请求到授权服务器,请求格式如下:

GET https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&client_id=APPID&client_secret=APPSECRET

这里有三个参数,含义如下:

grant_type,获取access_token填写client_credential
client_id 和 client_secret 用来确认客户端的身份

2.授权服务器通过验证后,会直接返回 access_token 给客户端。

大家发现,在这个过程中好像没有用户什么事了!是的,客户端模式给出的令牌,就是针对第三方应用的,而不是针对用户的。

实例

接下来是一个实例,包括客户端,资源服务器,授权服务器,实际上开发中我们往往只需要写客户端即可

我用一个表格来给大家整理下:

auth-server :8080 授权服务器
user-server :8081 资源服务器
client-app :8082 客户端

那我们首先来创建一个空的 Maven 父工程,创建好之后,里边什么都不用加,也不用写代码。我们将在这个父工程中搭建这个子模块

授权服务器搭建

首先我们搭建一个名为 auth-server 的授权服务,搭建的时候,选择如下三个依赖:

web,spring cloud security,spirng cloud OAuth2

项目创建完成后,首先提供一个 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("sang")
                .password(new BCryptPasswordEncoder().encode("123"))
                .roles("admin")
                .and()
                .withUser("javaboy")
                .password(new BCryptPasswordEncoder().encode("123"))
                .roles("user");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().formLogin();
    }
}

在这段代码中,为了代码简洁,我就不把 Spring Security 用户存到数据库中去了,直接存在内存中。

这里我创建了一个名为 sang 的用户,密码是 123,角色是 admin。同时我还配置了一个表单登录。

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

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

@Configuration
public class AccessTokenConfig {
    @Bean
    TokenStore tokenStore() {
        return new InMemoryTokenStore();
    }
}
@EnableAuthorizationServer
@Configuration
public class AuthorizationServer extends AuthorizationServerConfigurerAdapter {
    @Autowired
    TokenStore tokenStore;
    @Autowired
    ClientDetailsService clientDetailsService;

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

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("javaboy")
                .secret(new BCryptPasswordEncoder().encode("123"))
                .resourceIds("res1")
                .authorizedGrantTypes("authorization_code","refresh_token")
                .scopes("all")
                .redirectUris("http://localhost:8082/index.html");
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authorizationCodeServices(authorizationCodeServices())
                .tokenServices(tokenServices());
    }
    @Bean
    AuthorizationCodeServices authorizationCodeServices() {
        return new InMemoryAuthorizationCodeServices();
    }
}

这段代码有点长,我来给大家挨个解释:

1.首先我们提供了一个 TokenStore 的实例,这个是指你生成的 Token 要往哪里存储,我们可以存在 Redis 中,也可以存在内存中,也可以结合 JWT 等等,这里,我们就先把它存在内存中,所以提供一个 InMemoryTokenStore 的实例即可。
2.接下来我们创建 AuthorizationServer 类继承自 AuthorizationServerConfigurerAdapter,来对授权服务器做进一步的详细配置,AuthorizationServer 类记得加上 @EnableAuthorizationServer 注解,表示开启授权服务器的自动化配置。
3.在 AuthorizationServer 类中,我们其实主要重写三个 configure 方法。
4.AuthorizationServerSecurityConfigurer 用来配置令牌端点的安全约束,也就是这个端点谁能访问,谁不能访问。checkTokenAccess 是指一个 Token 校验的端点,这个端点我们设置为可以直接访问(在后面,当资源服务器收到 Token 之后,需要去校验 Token 的合法性,就会访问这个端点)。
5.ClientDetailsServiceConfigurer 用来配置客户端的详细信息,在上篇文章中,松哥和大家讲过,授权服务器要做两方面的检验,一方面是校验客户端,另一方面则是校验用户,校验用户,我们前面已经配置了,这里就是配置校验客户端。客户端的信息我们可以存在数据库中,这其实也是比较容易的,和用户信息存到数据库中类似,但是这里为了简化代码,我还是将客户端信息存在内存中,这里我们分别配置了客户端的 id,secret、资源 id、授权类型、授权范围以及重定向 uri。授权类型我在上篇文章中和大家一共讲了四种,四种之中不包含 refresh_token 这种类型,但是在实际操作中,refresh_token 也被算作一种。
6.AuthorizationServerEndpointsConfigurer 这里用来配置令牌的访问端点和令牌服务。authorizationCodeServices用来配置授权码的存储,这里我们是存在在内存中,tokenServices 用来配置令牌的存储,即 access_token 的存储位置,这里我们也先存储在内存中。有小伙伴会问,授权码和令牌有什么区别?授权码是用来获取令牌的,使用一次就失效,令牌则是用来获取资源的
7.tokenServices 这个 Bean 主要用来配置 Token 的一些基本信息,例如 Token 是否支持刷新、Token 的存储位置、Token 的有效期以及刷新 Token 的有效期等等。Token 有效期这个好理解,刷新 Token 的有效期我说一下,当 Token 快要过期的时候,我们需要获取一个新的 Token,在获取新的 Token 时候,需要有一个凭证信息,这个凭证信息不是旧的 Token,而是另外一个 refresh_token,这个 refresh_token 也是有有效期的。

好了,如此之后,我们的授权服务器就算是配置完成了,接下来我们启动授权服务器。

资源服务器搭建

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

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

我们创建一个新的 Spring Boot 项目,叫做 user-server ,作为我们的资源服务器,创建时,添加如下依赖:

项目创建成功之后,添加如下配置:

@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    @Bean
    RemoteTokenServices tokenServices() {
        RemoteTokenServices services = new RemoteTokenServices();
        services.setCheckTokenEndpointUrl("http://localhost:8080/oauth/check_token");
        services.setClientId("javaboy");
        services.setClientSecret("123");
        return services;
    }
    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.resourceId("res1").tokenServices(tokenServices());
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/admin/**").hasRole("admin")
                .anyRequest().authenticated();
    }
}

这段配置代码很简单,我简单的说一下:

1.tokenServices 我们配置了一个 RemoteTokenServices 的实例,这是因为资源服务器和授权服务器是分开的,资源服务器和授权服务器是放在一起的,就不需要配置 RemoteTokenServices 了。
2.RemoteTokenServices 中我们配置了 access_token 的校验地址、client_id、client_secret 这三个信息,当用户来资源服务器请求资源时,会携带上一个 access_token,通过这里的配置,就能够校验出 token 是否正确等。
3.最后配置一下资源的拦截规则,这就是 Spring Security 中的基本写法,我就不再赘述。

接下来我们再来配置两个测试接口:


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

如此之后,我们的资源服务器就算配置成功了。

客户端

客户端就是一个普通的 Spring Boot 工程,创建时加入 Thymeleaf 依赖和 Web 依赖

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

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

<a href="http://localhost:8080/oauth/authorize?client_id=javaboy&response_type=code&scope=all&redirect_uri=http://localhost:8082/index.html">第三方登录</a>

<h1 th:text="${msg}"></h1>
</body>
</html>

这是一段 Thymeleaf 模版,点击超链接就可以实现第三方登录,超链接的参数如下:

client_id 客户端 ID,根据我们在授权服务器中的实际配置填写。
response_type 表示响应类型,这里是 code 表示响应一个授权码。
redirect_uri 表示授权成功后的重定向地址,这里表示回到第三方应用的首页。
scope 表示授权范围。
h1 标签中的数据是来自资源服务器的,当授权服务器通过后,我们拿着 access_token 去资源服务器加载数据,加载到的数据就在 h1 标签中显示出来。

接下来我们来定义一个 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", "javaboy");
            map.add("client_secret", "123");
            map.add("redirect_uri", "http://localhost:8082/index.html");
            map.add("grant_type", "authorization_code");
            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);
            ResponseEntity<String> entity = restTemplate.exchange("http://localhost:8081/admin/hello", HttpMethod.GET, httpEntity, String.class);
            model.addAttribute("msg", entity.getBody());
        }
        return "index";
    }
}

在这个 HelloController 中,我们定义出 /index.html 的地址。

如果 code 不为 null,也就是如果是通过授权服务器重定向到这个地址来的,那么我们做如下两个操作:

1.根据拿到的 code,去请求 http://localhost:8080/oauth/token 地址去获取 Token,返回的数据结构如下:

{
    "access_token": "e7f223c4-7543-43c0-b5a6-5011743b5af4",
    "token_type": "bearer",
    "refresh_token": "aafc167b-a112-456e-bbd8-58cb56d915dd",
    "expires_in": 7199,
    "scope": "all"
}

access_token 就是我们请求数据所需要的令牌,refresh_token 则是我们刷新 token 所需要的令牌,expires_in 表示 token 有效期还剩多久。

2.接下来,根据我们拿到的 access_token,去请求资源服务器,注意 access_token 通过请求头传递,最后将资源服务器返回的数据放到 model 中。

这里我只是举一个简单的例子,目的是和大家把这个流程走通,正常来说,access_token 我们可能需要一个定时任务去维护,不用每次请求页面都去获取,定期去获取最新的 access_token 即可。后面的文章中,松哥还会继续完善这个案例,到时候再来和大家解决这些细节问题。

OK,代码写完后,我们就可以启动客户端开始测试了。

posted @ 2021-06-02 17:58  刚刚好。  阅读(498)  评论(0)    收藏  举报