Spring Security OAuth:客户端模式超简单实现
Posted 木兮同学
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Spring Security OAuth:客户端模式超简单实现相关的知识,希望对你有一定的参考价值。
文章目录
一、关于 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; /** * 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
类上的注解。
五、系列文章
Spring Security OAuth 系列
Spring Security 系列
- 《手把手教你如何使用Spring Security(上):登录授权》
- 《手把手教你如何使用Spring Security(中):接口认证》
- 《手把手教你如何使用Spring Security(下):访问控制》
- 《Spring Security源码(一):整体框架设计》
- 《Spring Security源码(二):建造者详解》
- 《Spring Security源码(三):HttpSecurity详解》
- 《Spring Security源码(四):配置器详解》
- 《Spring Security源码(五):FilterChainProxy是如何创建的?》
- 《Spring Security源码(六):FilterChainProxy是如何运行的?》
- 《Spring Security源码(七):设计模式在框架中的应用》
- 《Spring Security源码(八):登录认证源码流程》
- 《Spring Security源码(九):过滤器链上的过滤器是如何排序的?》
- 《Spring Security源码(十):权限访问控制是如何做到的?》
以上是关于Spring Security OAuth:客户端模式超简单实现的主要内容,如果未能解决你的问题,请参考以下文章
Spring security oauth 2 和客户端凭据流
Spring security Oauth2 客户端 ClientAuthenticationProcessingFilter