基本知识
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/