一、关于 OAuth
什么是 OAuth3.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) :也属于服务提供商,作用一个是保存用户资源,比如上面的用户信息,另一个是验证令牌的有效性。
OAuth3.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;
/**
* accessToken 过期时间
*/
private int accessExpireTimeInSecond = 2592000;
/**
* refreshToken 过期时间
*/
private int refreshExpireTimeInSecond = 86400;
/**
* 生成 token 的处理
*/
@Primary
@Bean
public DefaultTokenServices defaultTokenServices() {
DefaultTokenServices tokenServices = new DefaultTokenServices();
tokenServices.setTokenStore(tokenStore);
// 是否支持 refreshToken
tokenServices.setSupportRefreshToken(true);
// 是否复用 refreshToken,不复用的话是每次都会刷新token
tokenServices.setReuseRefreshToken(true);
// token 有效期自定义设置,默认12小时
tokenServices.setAccessTokenValiditySeconds(accessExpireTimeInSecond);
// 默认 30 天,这里修改
tokenServices.setRefreshTokenValiditySeconds(refreshExpireTimeInSecond);
return tokenServices;
}
/**
* 配置 ClientDetailsServiceConfigurer
*
* @param clients
* @throws Exception
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.withClientDetails((ClientDetailsService)oAuthClientDetailsService);
}
@Override
public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
// 开启 /oauth/token_key 验证端口无权限访问
oauthServer.tokenKeyAccess("permitAll()");
// 开启 /oauth/check_token 验证端口认证权限访问
oauthServer.checkTokenAccess("permitAll()");
// 开启后请求需要带上 client_id client_secret
oauthServer.allowFormAuthenticationForClients();
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
.tokenStore(tokenStore)
.authenticationManager(authenticationManager)
.tokenServices(defaultTokenServices())
// 获取token支持get方式和post方式
.allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST);
}
}
- Bean 声明(BeanConfig)
@Configuration
public class BeanConfig {
@Autowired
private RedisConnectionFactory connectionFactory;
/**
* 这里使用 Redis 存储 Token
*
* @return
*/
@Bean(name = "tokenStore")
public TokenStore tokenStore() {
RedisTokenStore tokenStore = new RedisTokenStore(connectionFactory);
// 设置存储 Token 信息的前缀
tokenStore.setPrefix("api:client:token:");
return tokenStore;
}
/**
* 如果需要使用 Spring Expression 表达式,需要配置这个 Bean(资源服务器校验接口权限需要配置这个)
*
* @param applicationContext
* @return
*/
@Bean
public OAuth2WebSecurityExpressionHandler oAuth2WebSecurityExpressionHandler(ApplicationContext applicationContext) {
OAuth2WebSecurityExpressionHandler expressionHandler = new OAuth2WebSecurityExpressionHandler();
expressionHandler.setApplicationContext(applicationContext);
return expressionHandler;
}
/**
* 配置 oAuthClientDetailsService Bean,
*
* @return
*/
@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();
// TODO 根据 clientId 查询该客户端所拥有的接口权限
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 {
// TODO 根据 clientId 查询数据库数据,组装出 ClientDetails 返回(注意这里的常量值都应该是数据库查询出来的)
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 {
// ignore...
}
@Override
public void updateClientDetails(ClientDetails clientDetails) throws NoSuchClientException {
// ignore...
}
@Override
public void updateClientSecret(String clientId, String secret) throws NoSuchClientException {
// ignore...
}
@Override
public void removeClientDetails(String clientId) throws NoSuchClientException {
// ignore...
}
@Override
public List<ClientDetails> listClientDetails() {
// ignore...
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;
/**
* 资源ID,唯一标识一个资源,oauth_client_details 的 resource_ids 需要有这个才能访问当前资源
*/
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 定义在 OAuthorizationServerConfig
.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 客户端模式的应用。
三、效果演示
准备阶段
- 准备接口(/hello、/bye、/ad)
/**
* 用户登录才可访问
*
* @return
*/
@GetMapping("/hello")
public String hello() {
return "Hello Spring Security!";
}
/**
* 用户登录才可访问
*
* @return
*/
@GetMapping("/bye")
public String bye() {
return "Bye Spring Security!";
}
/**
* 广告接口,匿名用户可以访问
*
* @return
*/
@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 类上的注解。