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):实现 ClientDetailsServiceClientRegistrationService 以及自定义的 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 OAuth:客户端模式超简单实现的主要内容,如果未能解决你的问题,请参考以下文章

spring-security-oauth2注解详解

spring-security-oauth2注解详解

Spring security oauth 2 和客户端凭据流

Spring security Oauth2 客户端 ClientAuthenticationProcessingFilter

社交登录,spring-security-oauth2 和 spring-security-jwt?

Spring Security OAuth:客户端模式超简单实现