一、OAuth2.0简介
关于OAuth2.0的介绍,网上有很多说明的文章了,这里就不做展开详细讲解,只是把必要的示意图贴上,再简单说明,方便后面复习。
如下是官方给出的认证过程示意图:
- Client,指发起认证流程的一方,比如某个APP、Web站点;
- Resource Owner,指在Resource Server上拥有资源的一方,需要访问Client,并允许Client从Resource Server获取到自己的信息;
- Authorization Server,为了保护Resource Owner在Resource Server上的资源,对Client进行认证和授权的服务;
- Resource Server,存放Resource Owner的资源,为Client提供获取Resource Owner的资源的服务;
我们再来举一个详细点的例子:
- Client,就是“黑马程序员”这个网站;
- Resource Owner,就是“用户”,想要利用自己在微信上的注册信息在“黑马程序员”这个网站实现注册登录;
- Authorization Server,就是“微信认证”,得到用户授权的情况下,把合法凭证令牌给到“黑马程序员”这个网站;
- Resource Server,就是“微信用户信息”这个服务,用户在其上拥有一些注册信息,根据合法的凭证令牌将信息给到“黑马程序员”这个网站;
二、准备工作
本案例中总共涉及四个角色,其中用户是自然人,不需要准备;其它三个角色都是程序代码,需要做一些准备工作。
我们创建一个父工程:security-oauth,主要的依赖有:
org.springframework.boot
spring-boot-starter-web
org.projectlombok
lombok
true
org.springframework.boot
spring-boot-starter-test
test
org.springframework.cloud
spring-cloud-dependencies
2020.0.3
pom
import
然后,我们依次创建三个子模块:
- auth-authorize,表示我们的授权服务,8081端口;
依赖信息:
org.springframework.cloud
spring-cloud-starter-oauth2
2.2.5.RELEASE
- auth-resource,表示我们的资源服务,8082端口;
依赖信息:
org.springframework.cloud
spring-cloud-starter-oauth2
2.2.5.RELEASE
- auth-client,表示我们的客户端,8080端口;
依赖信息:
org.springframework.boot
spring-boot-starter-thymeleaf
本篇文章主要讲解授权服务的实现,关于资源服务和客户端的示例在后面的篇文章中演示。
三、授权码模式
通过第一节的示意图我们知道,授权服务的主要作用就是对用户进行认证(用户密码登录),然后将用户的合法性(授权码、访问令牌)传递给客户端。
所以我们需要一个提供给用户的登录功能,还需要保留用户的账号密码,对用户进行认证,这个可以使用WebSecurityConfigurerAdapter
进行,这在原先讲解Spring Security的时候就说到了,如果不熟悉可以翻看原来的文章,此处不赘述。
@EnableWebSecurity
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private MyUserDetailsService userDetailsService;
/**
* 对请求进行鉴权的配置
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
// 没有权限进入内置的登录页面
.formLogin()
.and()
// 暂时关闭CSRF校验,允许get请求登出
.csrf().disable();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 使用userDetailsService进行认证
auth.userDetailsService(userDetailsService);
}
/**
* 密码加密器,供在UserDetailsService中验证密码时使用
* @return
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
相应的,我们需要一个UserDetailsService
来提供用户信息。
@Service
public class MyUserDetailsService implements UserDetailsService {
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 为了演示方便,使用内存定义用户的真实账密及其访问权限
return User
.withUsername("zhangxun")
.password(passwordEncoder.encode("mm123"))
// 设置当前用户可以拥有的权限信息,授权码模式下,用户输入账密后就拥有该权限
.authorities("user:query")
.build();
}
}
到此,我们的用户就可以使用账密登录授权服务了,但是此时还没有实现任何一点授权服务的功能,所以见下面。
我们先定义token令牌的管理策略,可以选择:
- 内存管理,默认管理策略,即令牌被创建后是保存在单机内存中的,因此适合授权服务是单机且并发量不大的场景下;
- JDBC管理,令牌被托管到数据库进行管理,适用于授权服务是集群的场景,不同机器之间可以通过数据库来共享token;
- JWT管理,授权服务不需要存储任何token,只需要对访问令牌进行计算即可验证token的合法性,也比较适合授权服务是集群的场景,而且是现在比较主流的使用方案;
本案例先使用内存管理token,其它方式在后面会介绍到。
@Configuration
public class TokenConfig {
@Bean
public TokenStore tokenStore(){
// 使用内存管理token策略
return new InMemoryTokenStore();
}
}
然后,就是我们的授权服务核心配置类了:
@Configuration
// 标记授权服务
@EnableAuthorizationServer
public class MyAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
// 授权码服务
@Autowired
private AuthorizationCodeServices authorizationCodeServices;
// 访问令牌服务
@Autowired
private AuthorizationServerTokenServices tokenServices;
// 访问令牌管理服务
@Autowired
private TokenStore tokenStore;
// 客户端服务,由于我们使用了内存模式,会自动创建一个默认的客户端服务
@Autowired
private ClientDetailsService clientDetailsService;
/**
* 配置客户端的详情,提供客户端的信息
*
* 客户端通过访问如下地址来获取授权码
* /oauth/authorize?client_id=iSchool&response_type=code&scope=all&redirect_uri=http://localhost:8080
* 客户端通过访问如下地址来获取访问token,访问token仅能使用一次
* /oauth/token?client_id=iSchool&client_secret=mysecret&grant_type=authorization_code&code=授权码&redirect_uri=http://localhost:8080
*
* @param clients
* @throws Exception
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients
// 基于内存方式存储客户信息
.inMemory()
// client_id,分配给客户端的标识
.withClient("iSchool")
// secret密钥,加密存储
.secret(new BCryptPasswordEncoder().encode("mysecret"))
// 当前仅开启授权码模式,refresh_token表示开启刷新令牌
.authorizedGrantTypes("authorization_code","refresh_token")
// 允许授权的范围,默认为空表示允许访问全部范围,这个在资源服务器那里用的到
.scopes("all")
// 资源服务器的ID配置,可以是多个,这个在资源服务器那里用的到
.resourceIds("user")
// 设置该client_id的主体所拥有的权限信息,在客户端模式下生效,在资源服务器那里用的到
.authorities("user:query")
// 需要用户手动授权,即会弹出界面需要用户手动点击授权
.autoApprove(false)
// 重定向地址,这里是第三方客户端的地址,用来接收授权服务器返回的授权码
.redirectUris("http://localhost:8080");
// 可以通过and()再添加其它的客户端信息,这里省略
}
/**
* 配置令牌的访问端点和令牌管理服务
* 默认的访问端点如下:
* /oauth/authorize:授权端点,获取授权码
* /oauth/token:令牌端点,获取访问令牌
* /oauth/confirm_access:用户确认授权提交端点
* /oauth/error:授权服务错误信息端点
* /oauth/check_token:提供给资源服务访问的令牌验证端点
* /oauth/token_key:提供公有密匙的端点,JWT模式使用
*
* @param endpoints
* @throws Exception
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
// 指定授权码管理策略
.authorizationCodeServices(authorizationCodeServices)
// 指定token管理策略,token会自己生成一个随机值
.tokenServices(tokenServices)
// 指定访问token的请求方法,实际应该使用POST方式,这里为了演示方便使用GET
.allowedTokenEndpointRequestMethods(HttpMethod.GET);
}
/**
* 配置令牌访问端点的安全约束
*
* @param security
* @throws Exception
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security
// 放开/oauth/check_token这个端点,供资源服务器调用来校验访问token的合法性
.checkTokenAccess("permitAll()")
// 开启表单认证
.allowFormAuthenticationForClients();
}
/**
* 配置授权码模式下授权码的存取方式,此时采用内存模式
* @return
*/
@Bean
public AuthorizationCodeServices authorizationCodeServices() {
return new InMemoryAuthorizationCodeServices();
}
/**
* 配置令牌管理服务
* @return
*/
@Bean
public AuthorizationServerTokenServices tokenServices() {
DefaultTokenServices services = new DefaultTokenServices();
// 配置客户端详情服务,获取客户端的信息
services.setClientDetailsService(clientDetailsService);
// 支持刷新令牌
services.setSupportRefreshToken(true);
// 配置令牌的存储方式,此时采用内存方式存储
services.setTokenStore(tokenStore);
// 访问令牌有效时间2小时
services.setAccessTokenValiditySeconds(7200);
// 刷新令牌的有效时间3天
services.setRefreshTokenValiditySeconds(259200);
return services;
}
}
具体的说明在如上代码中都已经注释说明了,到此,我们的授权码模式就算完成了。启动项目后,我们使用浏览器模拟第三方客户端发起授权请求:
http://localhost:8081/oauth/authorize?client_id=iSchool&response_type=code&scope=all&redirect_uri=http://localhost:8080
这个请求中包含的内容主要有:
- /oauth/authorize,这是访问端点,授权服务器对外暴露的,用于给第三方客户端生成授权码的接口;
- client_id,就是授权服务器分配给第三方客户端的标识,这里随便写一个iSchool,只要授权服务器上有这个客户信息即可;
- response_type,值code表示需要获取授权码;
- scope,值为all表示需要申请all这个域的资源访问权限,必须和上面配置中的一致;
- redirect_uri,即第三方客户端的回调地址,用来获取授权服务器返回的授权码;
请求发起后,页面就会进入登录页面,要求输入账密进行登录,此处即MyUserDetailsService
中写死的zhangxun/mm123
,登录成功后,就会跳转到授权页面,
需要注意到,授权页面有很多信息:
- 授权给谁?这里是iSchool这个client_id;
- 授权的范围?是all这个域的资源;
登录页面和授权页面都是可以定制的,这里为了简单演示,不做过度展开。
当我们授权成功后,授权服务器就重定向到第三方客户端的地址,并带过来一个授权码:
http://localhost:8080/?code=y4CwNB
第三方客户端拿到这个授权码之后,就将其传递给自己的后端服务器,由后端服务器再去调用授权服务器换取访问token。
这里并不是说一定要由后端服务器去获取token,而是token是一种需要保护的令牌,我们当然可以通过前端直接去获取token,但这会导致token被泄露在前端,而且还有第三方客户端的密钥,这些都是需要保密的内容。这里为了方便演示,就直接通过浏览器,使用前端调用授权服务器获取token:
http://localhost:8081/oauth/token?client_id=iSchool&client_secret=mysecret&grant_type=authorization_code&code=y4CwNB&redirect_uri=http://localhost:8080
然后会得到返回信息:
{"access_token":"1a6d94be-1f38-4140-bf2e-35b226a7346f","token_type":"bearer","refresh_token":"b41bfe84-717b-4bbe-9e38-2e30073fea29","expires_in":43199,"scope":"all"}
到此,我们就拿到了访问token。
四、简化模式
简化模式就是对授权码模式进行了简化,即第三方客户端访问授权服务器时不需要先获取授权码再获取访问token了,而是直接一步到位获取访问token。
首先,我们需要在授权服务器端的授权配置中开启简化模式:
// 支持的授权模式,refresh_token表示开启刷新令牌
.authorizedGrantTypes("implicit","refresh_token")
然后启动授权服务器即可,我们模拟第三方客户端对授权服务器发起请求如下,注意response_type改为了token:
http://localhost:8081/oauth/authorize?client_id=iSchool&response_type=token&scope=all&redirect_uri=http://localhost:8080
经过登录和授权之后,授权服务器就会重定向到第三方客户端的地址,并带回来访问token:
http://localhost:8080/#access_token=faa7813f-c9b2-4100-a11b-7d81d18af1f7&token_type=bearer&expires_in=43199
这样,第三方客户端就拿到了访问token,确实简化了不少,甚至都不用密钥,但是缺点也很明显,访问token在前端有泄露的风险,主要用于那些没有后端服务的第三方单页面应用,不是很推荐。
五、密码模式
密码模式是在授权码模式的基础上,将用户的账号密码给到第三方客户端,由第三方客户端带着用户的账密,以及它自己的标识和密钥来访问授权服务器,直接获取访问token,由此可以不用用户在授权服务器上进行登录和授权操作。
首先,我们需要开启密码模式:
.authorizedGrantTypes("password","refresh_token")
其次,为了支持第三方客户端可以将用户的账密带过来给到授权服务器,我们还需要在如上的MySecurityConfig
类中增加认证管理器:
/**
* 认证管理器,供密码模式下认证用户时使用
* @return
* @throws Exception
*/
@Bean
@Override
public AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
然后在我们的授权服务配置类MyAuthorizationServerConfig
中使用这个认证管理器:
@Autowired
private AuthenticationManager authenticationManager;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
// 指定认证管理器,在WebSecurityConfigurerAdapter的实现类中注入,密码模式需要用到
.authenticationManager(authenticationManager)
}
好了,现在启动授权服务后,模拟第三方客户端的后端服务对授权服务器发起请求如下:
http://localhost:8081/oauth/token?client_id=iSchool&client_secret=mysecret&grant_type=password&username=zhangxun&password=mm123
得到的返回内容为:
{"access_token":"325d0c02-89d5-4361-9930-bc91fa9255b0","token_type":"bearer","refresh_token":"b75bfb72-102f-4038-939d-b14b343eda0c","expires_in":43199,"scope":"all"}
这样,第三方客户端就拿到了访问token,但是,需要用户将自己在授权服务器上的账密泄露给第三方客户端,这对于很多授权服务方来说是不可忍受的,除非第三方客户端就是自己方的应用。
六、客户端模式
客户端模式也比较简单,只需要第三方客户端给出自己的标识和密钥,授权服务就返回给它访问token,甚至都不用用户的授权行为。
首先,我们需要开启客户端模式:
.authorizedGrantTypes("client_credentials","refresh_token")
然后可以将上述密码模式添加的认证管理器予以删除,重启授权服务器即可。
模拟第三方客户端的后端服务对授权服务器发起请求如下:
http://localhost:8081/oauth/token?client_id=iSchool&client_secret=mysecret&grant_type=client_credentials
得到的返回内容如下:
{"access_token":"885bd2f8-9ada-41b9-ac61-2c9a74a8b805","token_type":"bearer","expires_in":43199,"scope":"all"}
这样,第三方客户端就拿到了访问token,但是,这中间根本没有让用户进行授权,不能确保第三方客户端是否会对客户的信息用作非法用途,因此,只有第三方客户端是完全授信的情况下才能使用。
七、总结
综上四种模式中,授权码模式是最复杂,但是最安全的,也是现在业内最流行使用的方式;简化模式会导致访问token泄露到前端,安全性得不到保证;密码模式和客户端模式要求第三方客户端是受控制的,能得到完全信任的情况。
八、思考
7.1 授权码的必要性是什么?直接返回访问token不行吗?
不行。
- 授权码是为了将浏览器地址重定向到第三方客户端的网址,同时告知一个授权码;
- 授权码即使泄露,没有第三方客户端的密钥也是无法获取访问token的;
- 访问token是需要保护的令牌,不能在前端出现;
7.2 如何确保第三方客户端只能拿到授权用户的信息?
待研究