Spring Security Oauth2實踐(1) - 授權碼模式

筆者最近花了點時間研究Oauth2原理,基於spring寫了一些demo用於實踐探索,深深體會到Spring Security Oauth2框架的便利,框架幫我們封裝太多了底層實現,所以使用起來非常方便,除了可以爲第三方應用提供授權,也可以作爲應用的登錄框架。
文章基於Spring Security Oauth2.0授權碼模式實踐,採用SpringBoot搭建Demo講述基本使用過程。

概述

簡單來說Oauth2是一個授權協議,用於爲第三方訪問服務提供特定的授權流程。
它的最終目的是爲第三方應用頒發一個有時效性的令牌 token。使得第三方應用能夠通過該令牌獲取相關的資源。常見的場景就是:第三方登錄。當你想要登錄某個論壇,但沒有賬號,而這個論壇接入瞭如 QQ、Facebook 等登錄功能,在你使用 QQ 登錄的過程中就使用的OAuth 2.0協議。

在進入正式實踐章節之前,有必要先了解下Oauth2的關鍵知識點。關於Oauth2協議更多詳細內容,可以參考10 分鐘理解什麼是 OAuth 2.0 協議,是一個比較好的科普文章。

Oauth2.0協議中的幾個角色

  1. resource owner 資源所有者,能夠允許訪問受保護資源的實體。如果是個人,被稱爲end-user
  2. resource server 資源服務器,受保護資源的服務器,即訪問該服務器需要獲取授權或許可。
  3. client客戶端,使用資源所有者的授權代表資源所有者發起對受保護資源的請求的應用程序。如:web網站,移動應用等。
  4. authorization server,授權服務器,能夠向客戶端client頒發令牌。
  5. User-Agent,用戶代理,幫助資源所有者與客戶端溝通的工具,一般爲 web 瀏覽器,移動 APP 等。

幾個角色間的認證步驟

+--------+                               +---------------+
|        |--(A)- 請求用戶授權許可---- >   |   Resource    |
|        |                               |     Owner     |
|        |<-(B)-- 授權許可   ----------- |               |
|        |                               +---------------+
|        |
|        |                               +---------------+
|        |--(C)-- 獲取token ---------->  | Authorization |
| Client |                               |     Server    |
|        |<-(D)----- 發放token ----------|               |
|        |                               +---------------+
|        |
|        |                               +---------------+
|        |--(E)----- token訪問資源 ------>|    Resource   |
|        |                               |     Server    |
|        |<-(F)--- 返回受保護的資源 ------|               |
+--------+                               +---------------+

Spring Security Oauth2.0 幾個端點的作用

啓動項目時會有這發佈這些端點,用於Oauth2交互邏輯:

  1. 根據用戶認證獲得授權碼code AuthorizationEndpoint

{[/oauth/authorize]}
{[/oauth/authorize],methods=[POST]

  1. 客戶端根據授權碼code獲取令牌token TokenEndpoint

{[/oauth/token],methods=[GET]}
{[/oauth/token],methods=[POST]}

  1. 可以用於遠程解碼令牌 CheckTokenEndpoint

{[/oauth/check_token]}

  1. 顯示授權服務器的確認頁 WhitelabelApprovalEndpoint

{[/oauth/confirm_access]}

  1. 顯示授權服務器的錯誤頁 WhitelabelErrorEndpoint

{[/oauth/error]}

Oauth2.0四種授權模式

  • 授權碼模式(authorization code)
    最常用,可用於第三方應用授權、SSO登錄實現等。
  • 簡化模式(implicit)
    一般不用
  • 密碼模式(resource owner password credentials)
  • 客戶端模式(client credentials)
    一般用於後臺服務接口間的認證

實踐

搭建Oauth2應用,主要分爲4個步驟:搭建認證/授權服務器、搭建資源服務器、配置SpringSercurity、客戶端服務器。
github作爲授權服務器舉例說明授權碼模式的流程:

  1. 用戶訪問第三方應用A-Server,未登錄,A-Server提供github認證登錄;
  2. 用戶點擊github認證,跳轉到github
  3. 如用戶在github未登錄,跳轉到github登錄頁,用戶輸入賬戶密碼即可完成登錄,並跳轉到原來的第三方應用A-Server並得到頒佈的token;
  4. 如用戶已在github登錄,則跳轉到github確認授權頁,用戶點擊授權後即可跳轉到原來的第三方應用A-Server並得到頒佈的token

項目準備

  • 建立項目oauth-server,引入依賴
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>parent</artifactId>
        <groupId>com.oauth.demo</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>oauth-server</artifactId>
    <!-- security stater -->
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <version>1.5.10.RELEASE</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
            <version>1.5.3.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <version>1.5.10.RELEASE</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security.oauth</groupId>
            <artifactId>spring-security-oauth2</artifactId>
            <version>2.3.4.RELEASE</version>
        </dependency>

        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
            <version>2.9.0</version>
        </dependency>

        <!-- 數據庫 mybatis 連接池等相關配置 -->
        <dependency>
            <groupId>tk.mybatis</groupId>
            <artifactId>mapper-spring-boot-starter</artifactId>
            <version>1.2.3</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid</artifactId>
            <version>1.1.2</version>
        </dependency>

        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.data</groupId>
            <artifactId>spring-data-redis</artifactId>
            <version>1.8.3.RELEASE</version>
        </dependency>
    </dependencies>

</project>
  • 項目參數配置
server:
  port: 9011
  context-path: /auth
  session:
    cookie:
      name: OAUTH2SESSIONID
spring:
  application:
    name: auth-server

  # 數據庫配置
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    type: com.alibaba.druid.pool.DruidDataSource
    filters: stat,wall,log4j
    initialSize: 5
    minIdle: 5
    maxActive: 20
    maxWaite: 60000
    validationQuery: SELECT 1 FROM DUA
    url: jdbc:mysql://localhost:3306/oauth?useSSL=false&useUnicode=true&characterEncoding=utf-8
    username: root
    password: admin

redis:
  url: localhost
  port: 20806
  timeout: 20000
  password: test|test

mapper:
  mappers: com.oauth2.demo.mapper.BaseMapper
  not-empty: true
  identity: MYSQL
logging:
  level:
    root: debug

Oauth2服務端配置

數據庫結構

文中demo基於mysql數據庫存儲Oauth2相關數據。也可以採用內存簡單配置,但實際生產使用中需要用mysql等數據庫進行持久化配置。

CREATE TABLE `oauth_client_details` (
   `client_id` varchar(128) NOT NULL,
   `client_secret` varchar(256) NOT NULL,
   `resource_ids` varchar(256) DEFAULT NULL,
   `scope` varchar(1024) DEFAULT NULL,
   `authorized_grant_types` varchar(256) DEFAULT NULL,
   `web_server_redirect_uri` varchar(256) DEFAULT NULL,
   `authorities` varchar(2048) DEFAULT NULL,
   `access_token_validity` int(11) DEFAULT NULL,
   `refresh_token_validity` int(11) DEFAULT NULL,
   `additional_information` varchar(4096) DEFAULT NULL,
   `autoapprove` varchar(256) DEFAULT NULL,
   PRIMARY KEY (`client_id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='oauth2客戶端信息'

-- 添加一個客戶端
insert into `oauth_client_details` (`client_id`, `client_secret`, `resource_ids`, `scope`, `authorized_grant_types`, `web_server_redirect_uri`, `authorities`, `access_token_validity`, `refresh_token_validity`, `additional_information`, `autoapprove`) values('B5CDC04D8D8D419DA406364168F276A2','E2DA5B2DEDD548AEB7ABA689436C2E2C','','userProfile','authorization_code,client_credentials,password','http://localhost:9101/client1/login,http://www.baidu.com','','43200','2592000','','');

CREATE TABLE `oauth_access_token` (
   `token_id` varchar(256) DEFAULT NULL,
   `token` blob,
   `authentication_id` varchar(128) NOT NULL,
   `user_name` varchar(256) DEFAULT NULL,
   `client_id` varchar(256) DEFAULT NULL,
   `authentication` blob,
   `refresh_token` varchar(256) DEFAULT NULL,
   PRIMARY KEY (`authentication_id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='oauth2訪問令牌'


CREATE TABLE `oauth_code` (
   `code` varchar(256) DEFAULT NULL,
   `authentication` blob,
   `create_ts` timestamp NULL DEFAULT CURRENT_TIMESTAMP
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='oauth2授權碼'

CREATE TABLE `oauth_approvals` (
   `userId` varchar(256) DEFAULT NULL,
   `clientId` varchar(256) DEFAULT NULL,
   `scope` varchar(256) DEFAULT NULL,
   `status` varchar(10) DEFAULT NULL,
   `expiresAt` datetime DEFAULT NULL,
   `lastModifiedAt` datetime DEFAULT NULL
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='oauth2已授權客戶端'

授權服務器

用於實現Oauth2認證,關鍵配置@EnableAuthorizationServer表示開啓Oauth2認證服務器

@Configuration
@EnableAuthorizationServer
public class AuthServerConfiguration extends AuthorizationServerConfigurerAdapter{

    @Autowired
    private OauthClientDetailsServiceImpl oauthClientDetailsService;
    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private UserDetailServiceImpl userDetailService;
    @Autowired
    private DruidDataSource dataSource;
    @Autowired
    private JedisConnectionFactory jedisConnectionFactory;

    /**
     * 已授權客戶端存儲記錄
     * @return
     */
    @Bean
    public ApprovalStore approvalStore() {
        return new JdbcApprovalStore(dataSource);
    }

    /**
     * access_token 採用redis緩存,Redis連接池採用Jedis框架
     * 當然也可以採用Jdbc實現用mysql存儲
     * @return
     */
    @Bean
    public RedisTokenStore tokenStore() {
        return new RedisTokenStore(jedisConnectionFactory);
    }

    /**
     * 授權碼code的存儲-mysql中
     * @return
     */
    @Bean
    public AuthorizationCodeServices authorizationCodeServices() {
        JdbcAuthorizationCodeServices service = new JdbcAuthorizationCodeServices(dataSource);
        return service;
    }
    // 配置Oauth2客戶端,存儲到數據庫中
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.withClientDetails(oauthClientDetailsService);
    }

    /**
     * 配置oauth2端點信息
     * @param endpoints
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints
                .allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST)
                .authenticationManager(authenticationManager)
                .approvalStore(approvalStore())
                .userDetailsService(userDetailService)
                .tokenStore(tokenStore())
                .authorizationCodeServices(authorizationCodeServices());
        // 自定義確認授權頁面
        endpoints.pathMapping("/oauth/confirm_access", "/oauth/confirm_access");
        // 自定義錯誤頁
        endpoints.pathMapping("/oauth/error", "/oauth/error");

    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {

        security.checkTokenAccess("isAuthenticated()")
                .allowFormAuthenticationForClients();  //主要是讓/oauth/token支持client_id以及client_secret作登錄認證
    }
}

配置SpingSecurity

用於保護oauth相關的endpoints,主要作用於用戶的登錄(form login,Basic auth),主要是通過一系列過濾鏈實現登錄相關

/**
 * Web服務配置類
 */
@Slf4j
@Configuration
@EnableWebSecurity
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailServiceImpl userDetailsService;

    @Autowired
    private BCryptPasswordEncoder passwordEncoder;

    /**
     * 不定義沒有password grant_type模式
     * @return
     * @throws Exception
     */
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
    * 配置用戶賬密的處理類   userDetailsService提供登錄用戶的賬密信息供springsecurity框架校驗
    */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService)
                .passwordEncoder(passwordEncoder);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
                .and()
                .requestMatchers()  //所有端點配置
                .antMatchers("/oauth/**", "/login")  // 匹配一個數據 ant路徑格式
                .and()
                .authorizeRequests()  // url權限配置
                .antMatchers("/login").permitAll()  // 表示登錄表單頁面不攔截
                .antMatchers("/oauth/**").authenticated()  // 保護url,需要用戶登錄
                .and()
                .formLogin().permitAll()  //沒有自定義loginpage  則不要寫上loginPage("/xxxx") 否則404
                .and()
                .logout().permitAll()
                // /logout退出清除cookie
                .addLogoutHandler(new CookieClearingLogoutHandler("token", "remember-me"))
                .and()
                .csrf().disable()
                // 禁用httpBasic
                .httpBasic().disable();
    }

}

資源服務器

配置受保護的資源,用於保護oauth受限資源,主要作用於client端以及token的認證,security配置WebSecurityConfigurerAdapter與資源服務配置ResourceServerConfigurerAdapter比較像,它們的區別會在本文末講到。

@Slf4j
@Configuration
@EnableResourceServer
public class ResourceConfiguration extends ResourceServerConfigurerAdapter {
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
                .requestMatchers().antMatchers("/userInfo")
                .and()
                .authorizeRequests()
                .antMatchers("/userInfo").authenticated();  //受保護資源url: /userInfo 需要認證
    }
}

用戶信息接口(受保護資源)

@Slf4j
@Controller
public class HomeController {
    /**
    * 提供通過access_token獲取用戶信息的問題
    * @return
    */
    @RequestMapping("userInfo")
    @ResponseBody
    public Object userInfo(Principal principal) {        
        return principal
    }
}

其它配置

通用配置

@Configuration
public class CommonConfiguration {

    /**
     * 賬戶密碼加密類
     * @return
     */
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

Redis連接池配置:這裏採用的是spring-data-redis自定義配置,不需要依賴starter

@Configuration
public class JedisPoolConfiguration {

    @Value("${redis.url}")
    private String redisUrl;
    @Value("${redis.port}")
    private int redisPort;
    @Value("${redis.timeout}")
    private int redisTimeout;
    @Value("${redis.password}")
    private String redisPasswd;

    @Bean
    public JedisConnectionFactory connectionFactory() {
        JedisConnectionFactory jedisConnectionFactory = new JedisConnectionFactory();
        jedisConnectionFactory.setPort(redisPort);
        jedisConnectionFactory.setHostName(redisUrl);
        jedisConnectionFactory.setPassword(redisPasswd);
        return jedisConnectionFactory;
    }
}

用戶賬密處理類:文章簡單處理,允許所有的賬號用密碼111111登錄。其中 UserInfoEntity 繼承 UserDetails 類。

@Configuration
public class UserDetailServiceImpl implements UserDetailsService{

    @Autowired
    private BCryptPasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        UserInfoEntity userInfoEntity = new UserInfoEntity();
        userInfoEntity.setUsername(s);
        userInfoEntity.setPassword(passwordEncoder.encode("111111"));
        return userInfoEntity;
    }
}

Oauth2客戶端業務類:文章採用mybatis框架實現ORM

@Slf4j
@Configuration
public class OauthClientDetailsServiceImpl implements ClientDetailsService{

    @Autowired
    private OauthClientDetailsDao oauthClientDetailsDao;
    /**
     * 根據id client_id獲取客戶端信息
     * @param s
     * @return
     * @throws ClientRegistrationException
     */
    public ClientDetails loadClientByClientId(String s) throws ClientRegistrationException {
        // 根據Id查詢
        OauthClientDetails oauthClientDetails = oauthClientDetailsDao.selectByPrimaryKey(s);
        try {
            return translateClient(oauthClientDetails);
        } catch (Exception e) {
            log.error("===> {}", e);
            throw new ClientRegistrationException("無效client");
        }
    }

    public ClientDetails translateClient(OauthClientDetails details) {
        BaseClientDetails clientDetails = new BaseClientDetails(details.getClientId(), details.getResourceIds(), details.getScope(),
                details.getAuthorizedGrantTypes(), details.getAuthorities(), details.getWebServerRedirectUri());
        clientDetails.setClientSecret(details.getClientSecret());
        clientDetails.setScope(StringUtils.commaDelimitedListToSet(details.getScope()));
        clientDetails.setAutoApproveScopes(new ArrayList<String>());
        clientDetails.setRefreshTokenValiditySeconds(details.getRefreshTokenValidity());
        clientDetails.setAccessTokenValiditySeconds(details.getAccessTokenValidity());
        return clientDetails;
    }
}

至此,Oauth2+ 資源 就配置完成,下面進行測試。

測試

  • 獲取code,跳轉到登錄頁/login,輸入賬號密碼admin/111111,登錄後返回code
    http://localhost:9011/auth/oauth/authorize?client_id=B5CDC04D8D8D419DA406364168F276A2&response_type=code&redirect_uri=http://localhost:9101/client1/login

用戶登錄

登錄成功後獲取到的授權code爲9cB8ks (生產環境一般是由localhost:9101/client1/login前端將code傳給後端)
授權code

  • 通過code獲取令牌token(POST請求)
    http://localhost:9011/auth/oauth/token?client_id=B5CDC04D8D8D419DA406364168F276A2&client_secret=E2DA5B2DEDD548AEB7ABA689436C2E2C&grant_type=authorization_code&redirect_uri=http://localhost:9101/client1/login&code=9cB8ks

獲取access_token

  • 通過access_token訪問資源:獲取用戶信息
    訪問資源url
    這樣就完成了一個基於spring security oauth2授權碼模式的認證服務。

WebSecurityConfigurerAdapter與ResourceServerConfigurerAdapter

二者都有針對http security的配置,都可用於攔截驗證url甚至有相同的作用效果,但在功能及使用場景上有區別

  • 過濾順序
    WebSecurityConfigurerAdapter(order=100)的攔截要低於@EnableResourceServer(order=3)
    WebSecurityConfigurerAdapter用於保護oauth相關的endpoints,同時主要作用於用戶的登錄(form login,Basic auth)
  • 使用場景
    ResourceServerConfigurerAdapter用於保護oauth要開放的資源,主要作用於client端以及token的認證(Bearer auth)
  • 兩者分工協作
    因此WebSecurityConfigurerAdapter需要攔截oauth資源,不攔截保護資源;而被保護資源由ResourceServerConfigurerAdapter攔截,配置需要acess_token訪問的url

總結

文章簡單介紹Oauth2協議的過程,spring sercurity oauth2框架實現及最常用的授權碼模式的實踐。其它功能或實踐,比如密碼登錄模式、客戶端如何接入使用、sso統一登錄等,筆者後續有空會繼續更新實踐。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章