一、关于 OAuth
什么是 OAuth2.0?
- OAuth(开放授权)是一个
开放标准 ,允许用户让第三方应用访问该用户在某一网站上存储的私密的资源(如照片,视频,联系人列表),而无需将用户名和密码提供给第三方应用。OAuth2.0 是 OAuth 协议的延续版本,但不向前兼容 OAuth1.0,即完全废止了 OAuth1.0(文中提到的默认都是 OAuth2.0)。
应用场景
- 第三方应用授权登录:在 APP 或者网页接入一些第三方应用时,很多时候会有一些
授权登录 按钮,比如 QQ、微博、微信的授权登录。 - 原生 app 授权:app 登录请求后台接口,为了安全认证,所有请求都带
Token 信息 ,再进行登录验证、请求后台数据。 - 前后端分离单页面应用:前后端分离框架,前端请求后台数据,需要
进行 OAuth2.0 安全认证 ,比如使用 vue、react 或者 H5 开发的应用如小程序等。
OAuth 协议中的各种角色及职责
- 服务提供商(Provider):
提供授权许可、访问令牌 等。 - 资源所有者(Resource Owner):用户名、昵称、头像
信息的所有者 ,即用户,可以同意或者拒绝授权。 - 第三方应用(Client):
比如说博客 ,它要把微信的用户变成自己的用户,就需要微信授权给博客。 - 认证服务器(Authorization Server):属于服务提供商,主要责任是
认证用户身份,并且产生令牌 。 - 资源服务器(Resource Server):也属于服务提供商,作用一个是
保存用户资源 ,比如上面的用户信息,另一个是验证令牌 的有效性。
OAuth2.0 的四种授权模式
授权码模式(authorization code) :授权码模式是四种模式中功能最完整,流程最严密的授权模式,互联网上能看到的所有的提供商,微博、微信、QQ、百度等都采用的是授权码模式来完成 OAuth 流程的。简化模式(implicit) :有些第三方网站没有专门的服务器,这种时候就可以用简化模式,就是从认证服务器返回到第三方应用的时候直接带的就是令牌,不支持刷新令牌,令牌容易因为被拦截窃听而泄露,所以安全性上授权码模式是更高的。密码模式(resource owner password credentials) :用户向客户端提供用户名密码,使用用户名、密码作为授权方式发给认证服务器请求令牌,认证服务器确认无误后,向客户端提供访问令牌,一般不支持刷新令牌。客户端模式(client credentials) :客户端向认证服务器进行身份认证,并请求一个访问令牌,认证服务器确认无误后,向客户端提供访问令牌。
关于更多 OAuth2.0 知识请参考官方介绍
二、Spring Security OAuth 实战
- Spring Security 对 OAuth 进行了一个实现,可以让开发者更加简易地使用 OAuth,就是本篇主角 Spring Security OAuth。
场景介绍
公司需要对外(一些第三方)提供一些接口资源,但是我们不能直接把资源暴露出去,我们需要保护好资源,就是访问接口时需要校验第三方请求的合法性。
- 首先,初步想法我们可以为这些第三方做一个
登录认证 ,访问接口时校验 Token 的合法性,这种方式当然可以满足我们的需求,但是这样却略显复杂,还需要对第三方的用户信息进行维护,这其实 duck 不必,因为这些信息我们并不关心。 - 其实,这个场景无非就是要判断第三方请求是否合法,完全可以使用
OAuth 客户端模式 来快速的完成我们的需求。 - 具体,我们可以给到每个第三方一个
client_id 和 client_secret ,第三方拿着它们请求认证服务器的认证接口 /oauth/token 得到 access_token 令牌,再拿着令牌请求我们资源服务器的接口资源,当然资源接口还会对 access_token 进行校验。 - 正好,
Spring Security OAuth 已经给我们提供了 OAuth 实现,实现起来也非常简单,一起来看看。
准备工作
- 要实现的是一个认证服务和资源服务,
认证服务作用是提供 Token,资源服务是校验 Token 并且提供接口资源 。 - 需要提供存储保存为第三方提供的
client_id 和 client_secret ,此外还包括:
create table oauth_client_details
(
id bigint auto_increment comment '主键ID'
primary key,
client_name varchar(255) not null comment '客户端名称',
client_id varchar(255) not null comment '客户端标识',
client_secret varchar(255) null comment '客户端访问密匙',
resource_ids varchar(255) null comment '客户端所能访问的资源 id 集合,多个资源时用逗号(,)分隔',
scope varchar(255) null comment '指定客户端申请的权限范围,可选值包括 read,write,trust;若有多个权限范围用逗号(,)',
authorized_grant_types varchar(255) null comment '客户端支持的 OAuth 模式,可选值包括 authorization_code,password,refresh_token,implicit,client_credentials, 若支持多个 grant_type 用逗号(,)分隔',
web_server_redirect_uri varchar(255) null comment '客户端的重定向 URI,可为空',
access_token_validity int null comment 'access_token 的有效时间值(单位:秒)',
refresh_token_validity int null comment 'refresh_token 的有效时间值(单位:秒)',
authority varchar(255) null comment '当前客户端可访问的权限接口, “请求动词 + 空格 + 接口”格式,多个用逗号隔开',
create_time timestamp default CURRENT_TIMESTAMP not null comment '创建时间',
create_date timestamp default CURRENT_TIMESTAMP not null comment '创建时间',
create_user varchar(32) null comment '创建人',
modify_date timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '修改时间',
modify_user varchar(32) null comment '修改人'
)
comment '开放平台客户端' charset = utf8mb4;
- 还需要利用 Redis 储存 Token,因为认证服务和资源服务是分布式部署,甚至也可以拆分资源部署不同的资源服务器而共用一个认证服务器。
认证服务配置(OAuthServerConfig)
- OAuth2 认证服务配置(OAuthServerConfig):继承
AuthorizationServerConfigurerAdapter 类,覆盖该类的三个方法(源码篇介绍为何要继承这个类,文章末尾给出源码篇地址)@Configuration
@EnableAuthorizationServer
public class OAuthServerConfig extends AuthorizationServerConfigurerAdapter{
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private OAuthClientDetailsService oAuthClientDetailsService;
@Autowired
private TokenStore tokenStore;
private int accessExpireTimeInSecond = 2592000;
private int refreshExpireTimeInSecond = 86400;
@Primary
@Bean
public DefaultTokenServices defaultTokenServices() {
DefaultTokenServices tokenServices = new DefaultTokenServices();
tokenServices.setTokenStore(tokenStore);
tokenServices.setSupportRefreshToken(true);
tokenServices.setReuseRefreshToken(true);
tokenServices.setAccessTokenValiditySeconds(accessExpireTimeInSecond);
tokenServices.setRefreshTokenValiditySeconds(refreshExpireTimeInSecond);
return tokenServices;
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.withClientDetails((ClientDetailsService)oAuthClientDetailsService);
}
@Override
public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
oauthServer.tokenKeyAccess("permitAll()");
oauthServer.checkTokenAccess("permitAll()");
oauthServer.allowFormAuthenticationForClients();
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
.tokenStore(tokenStore)
.authenticationManager(authenticationManager)
.tokenServices(defaultTokenServices())
.allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST);
}
}
- Bean 声明(BeanConfig)
@Configuration
public class BeanConfig {
@Autowired
private RedisConnectionFactory connectionFactory;
@Bean(name = "tokenStore")
public TokenStore tokenStore() {
RedisTokenStore tokenStore = new RedisTokenStore(connectionFactory);
tokenStore.setPrefix("api:client:token:");
return tokenStore;
}
@Bean
public OAuth2WebSecurityExpressionHandler oAuth2WebSecurityExpressionHandler(ApplicationContext applicationContext) {
OAuth2WebSecurityExpressionHandler expressionHandler = new OAuth2WebSecurityExpressionHandler();
expressionHandler.setApplicationContext(applicationContext);
return expressionHandler;
}
@Bean(name = "oAuthClientDetailsService")
public OAuthClientDetailsService oAuthClientDetailsService() {
return new OAuthClientDetailsServiceImpl();
}
}
- OAuth 授权客户端信息服务类(OAuthClientDetailsServiceImpl):实现
ClientDetailsService 和 ClientRegistrationService 以及自定义的 OAuthClientDetailsService 接口,主要提供了按照 client_id 查找客户端方法以及查询当前客户端所拥有接口权限的方法。@Service
@Slf4j
public class OAuthClientDetailsServiceImpl implements OAuthClientDetailsService, ClientDetailsService, ClientRegistrationService {
@Override
public Boolean hasPermission(HttpServletRequest request, Authentication authentication) {
String clientId = (String) authentication.getPrincipal();
Set<String> urls = new HashSet<>();
urls.add("GET /hello");
if (urls.size() > 0) {
return urls.stream().anyMatch(
url -> url.equalsIgnoreCase(request.getMethod() + " " + request.getServletPath()));
}
return false;
}
@Override
public ClientDetails loadClientByClientId(String clientId) throws InvalidClientException {
if ("4099c23e45f64c158065e1b062492357".equalsIgnoreCase(clientId)) {
BaseClientDetails details = new BaseClientDetails("4099c23e45f64c158065e1b062492357",
"security_oauth_demo_resource_id", "read,write",
"client_credentials,refresh_token,authorization_code", null,
null);
details.setClientSecret("f5b351eb6df8458382d0303aae8a72d7275a2296ff45488c9f135ca120edebd1");
return details;
} else {
log.error("查询授权客户端异常:e={}", "客户端不存在");
throw OAuthenticationException.CLIENT_QUERY_FAIL;
}
}
@Override
public void addClientDetails(ClientDetails clientDetails) throws ClientAlreadyExistsException {
}
@Override
public void updateClientDetails(ClientDetails clientDetails) throws NoSuchClientException {
}
@Override
public void updateClientSecret(String clientId, String secret) throws NoSuchClientException {
}
@Override
public void removeClientDetails(String clientId) throws NoSuchClientException {
}
@Override
public List<ClientDetails> listClientDetails() {
return new ArrayList<>();
}
}
资源服务配置(OAuthResourceConfig)
- OAuth2.0 资源服务配置(OAuthResourceConfig):继承
ResourceServerConfigurerAdapter 类,主要任务是判断当前请求是否合法,包括校验 Token 的正确性,接口是否有权限等。@EnableResourceServer
@Configuration
@Slf4j
public class OAuthResourceConfig extends ResourceServerConfigurerAdapter {
@Autowired
private TokenStore tokenStore;
@Autowired
private OAuth2WebSecurityExpressionHandler oAuth2WebSecurityExpressionHandler;
public static final String RESOURCE_ID = "security_oauth_demo_resource_id";
protected String[] permitUrls = new String[]{"/ad"};
@Override
public void configure(ResourceServerSecurityConfigurer resources)throws Exception{
resources
.resourceId(RESOURCE_ID)
.tokenStore(tokenStore)
.expressionHandler(oAuth2WebSecurityExpressionHandler);
}
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers(HttpMethod.OPTIONS).permitAll()
.antMatchers(permitUrls).permitAll()
.anyRequest().access("@oAuthClientDetailsService.hasPermission(request, authentication)")
.and()
.httpBasic()
.and().csrf().disable();
}
}
- 至此,所有代码已完成,是不是很惊讶才这几行就已经完成了认证服务和资源服务,是的没错,就这几个类就完成了 OAuth 客户端模式的应用。
三、效果演示
准备阶段
@GetMapping("/hello")
public String hello() {
return "Hello Spring Security!";
}
@GetMapping("/bye")
public String bye() {
return "Bye Spring Security!";
}
@GetMapping("/ad")
public String no() {
return "妈妈再也不用担心我的学习!";
}
- 准备客户端配置,上面代码模拟做了如下数据库配置,便于维护,这些配置都要配到数据库中的,这里直接写死代码中作了简化处理。
// 分配给第三方的 client_id
client_id = 4099c23e45f64c158065e1b062492357
// 分配给第三方的 client_secret
client_secret = f5b351eb6df8458382d0303aae8a72d7275a2296ff45488c9f135ca120edebd1
// 能访问的资源服务器
resourceIds = security_oauth_demo_resource_id
// 当前客户端拥有的作用域
scopes = read,write
// 当前客户端可使用的模式
grantTypes = client_credentials,refresh_token,authorization_code
// 当前客户端能访问的接口(也作了简化处理,写死在代码中)
urls = GET /hello
获取 Token
- 根据分配的 client_id 和 client_secret,以及指定 grant_type 为客户端模式,访问
/oauth/token 接口获取 access_token 。
访问资源
- 成功演示,请求头带上 access_token,访问
/hello 接口,注意一定要 Bearer 开头,空格隔开再拼接 access_token,具体为何要这样,请看文末源码篇。 - 失败演示,访问
/bye 接口,报无权限访问 - 失败演示,不带上令牌
四、总结
- OAuth(开放授权)是一个开放标准,Spring Security OAuth 给我们提供了一个 OAuth 的实现,本篇演示了如何使用 Spring Security OAuth 作为客户端模式的应用。
- 主要工作在于要实现认证服务器和资源服务器,认证服务器继承
AuthorizationServerConfigurerAdapter 类,资源服务器继承 ResourceServerConfigurerAdapter 类。 - 本案例认证服务和资源服务部署在了同一个模块,按照规范来说认证服务和资源服务应该是需要
分开部署 的,这里都做了简易处理。 - 全篇都在强行告知要怎么做,却并未告知为什么这么做,关于为什么,请参考源码篇。
- 源码奉上:Github 项目地址:spring-security-demo,要注意启动之前注释掉
WebSecurityConfig 类上的注解。
五、系列文章
Spring Security OAuth 系列
Spring Security 系列
|