1. 程式人生 > >Spring Security Oauth2 單點登入案例實現和執行流程剖析

Spring Security Oauth2 單點登入案例實現和執行流程剖析

線上演示

演示地址:http://139.196.87.48:9002/kitty

使用者名稱:admin 密碼:admin

Spring Security Oauth2

OAuth是一個關於授權的開放網路標準,在全世界得到的廣泛的應用,目前是2.0的版本。OAuth2在“客戶端”與“服務提供商”之間,設定了一個授權層(authorization layer)。“客戶端”不能直接登入“服務提供商”,只能登入授權層,以此將使用者與客戶端分離。“客戶端”登入需要獲取OAuth提供的令牌,否則將提示認證失敗而導致客戶端無法訪問服務。關於OAuth2這裡就不多作介紹了,網上資料詳盡。下面我們實現一個 整合 SpringBoot 、Spring Security OAuth2 來實現單點登入功能的案例並對執行流程進行詳細的剖析。

案例實現

專案介紹

這個單點登入系統包括下面幾個模組:

spring-oauth-parent : 父模組,管理打包

spring-oauth-server : 認證服務端、資源服務端(埠:8881)

spring-oauth-client  : 單點登入客戶端示例(埠:8882)

spring-oauth-client2: 單點登入客戶端示例(埠:8883)

當通過任意客戶端訪問資源伺服器受保護的介面時,會跳轉到認證伺服器的統一登入介面,要求登入,登入之後,在登入有效時間內任意客戶端都無需再登入。

認證服務端

新增依賴

主要是新增 spring-security-oauth2 依賴。

pom.xml

<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">
    <modelVersion>4.0.0</modelVersion>
    
    <artifactId>spring-oauth-server</artifactId>
    <name>spring-oauth-server</name>
    <packaging>war</packaging>

    <parent>
        <groupId>com.louis</groupId>
        <artifactId>spring-oauth-parent</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security.oauth</groupId>
            <artifactId>spring-security-oauth2</artifactId>
            <version>${oauth.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-freemarker</artifactId>
        </dependency>
    </dependencies>

</project>

配置檔案

配置檔案內容如下。

application.yml

server:
  port: 8881
  servlet:
    context-path: /auth
  

啟動類

啟動類新增 @EnableResourceServer 註解,表示作為資源伺服器。  

OAuthServerApplication.java

package com.louis.spring.oauth.server;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;

@SpringBootApplication
@EnableResourceServer
public class OAuthServerApplication extends SpringBootServletInitializer { public static void main(String[] args) { SpringApplication.run(OAuthServerApplication.class, args); } }

認證服務配置

新增認證伺服器配置,這裡採用記憶體方式獲取,其他方式獲取在這裡定製即可。

OAuthServerConfig.java

package com.louis.spring.oauth.server.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;

@Configuration
@EnableAuthorizationServer
public class OAuthServerConfig extends AuthorizationServerConfigurerAdapter {
    
    @Autowired    
    private BCryptPasswordEncoder passwordEncoder;
    
    @Override
    public void configure(final AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
        oauthServer.tokenKeyAccess("permitAll()").checkTokenAccess("isAuthenticated()");
    }

    @Override
    public void configure(final ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
            .withClient("SampleClientId") // clientId, 可以類比為使用者名稱
            .secret(passwordEncoder.encode("secret")) // secret, 可以類比為密碼
            .authorizedGrantTypes("authorization_code")    // 授權型別,這裡選擇授權碼
            .scopes("user_info") // 授權範圍
            .autoApprove(true) // 自動認證
            .redirectUris("http://localhost:8882/login","http://localhost:8883/login")    // 認證成功重定向URL
            .accessTokenValiditySeconds(10); // 超時時間,10s 
    }

}

安全配置

Spring Security 安全配置。在安全配置類裡我們配置了:

1. 配置請求URL的訪問策略。

2. 自定義了同一認證登入頁面URL。

3. 配置使用者名稱密碼資訊從記憶體中建立並獲取。

SecurityConfig.java

package com.louis.spring.oauth.server.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@Configuration
@Order(1)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.requestMatchers()
            .antMatchers("/login")
            .antMatchers("/oauth/authorize")
            .and()
            .authorizeRequests()
            .anyRequest().authenticated()
            .and()
            .formLogin().loginPage("/login").permitAll()    // 自定義登入頁面,這裡配置了 loginPage, 就會通過 LoginController 的 login 介面載入登入頁面
            .and().csrf().disable();
        
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 配置使用者名稱密碼,這裡採用記憶體方式,生產環境需要從資料庫獲取
        auth.inMemoryAuthentication()
            .withUser("admin")
            .password(passwordEncoder().encode("123"))
            .roles("USER");
    }

    @Bean
    public BCryptPasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

介面提供

這裡提供了一個自定義的登入介面,用於跳轉到自定義的同一認證登入頁面。

LoginController.java

package com.louis.spring.oauth.server.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class LoginController {

    /**
     * 自定義登入頁面
     * @return
     */
    @GetMapping("/login")
    public String login() {
        return "login";
    }

}

登入頁面放置在 resources/templates 下,需要在登入時提交 pos t表單到 auth/login。

login.ftl

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Insert title here</title>
    <link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css">
    <script src="https://cdn.bootcss.com/vue/2.5.17/vue.min.js"></script>
    <script src="https://unpkg.com/element-ui/lib/index.js"></script>
    <script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.js"></script>
</head>

<body>
<div class="login-box" id="app" >
   <el-form action="/auth/login" method="post" label-position="left" label-width="0px" class="demo-ruleForm login-container">
    <h2 class="title" >統一認證登入平臺</h2>
    <el-form-item>
      <el-input type="text"  name="username" v-model="username" auto-complete="off" placeholder="賬號"></el-input>
    </el-form-item>
    <el-form-item>
      <el-input type="password" name="password" v-model="password" auto-complete="off" placeholder="密碼"></el-input>
    </el-form-item>
    <el-form-item style="width:100%; text-align:center;">
      <el-button type="primary" style="width:47%;" @click.native.prevent="reset">重 置</el-button>
      <el-button type="primary" style="width:47%;" native-type="submit" :loading="loading">登 錄</el-button>
    </el-form-item>
  <el-form>
</div> 
</body>
 
<script type="text/javascript">
    new Vue({
        el : '#app',
        data : {
            loading: false,
            username: 'admin',
            password: '123'
        },
        methods : {
        }
    })
    
</script>

<style lang="scss" scoped>
  .login-container {
    -webkit-border-radius: 5px;
    border-radius: 5px;
    -moz-border-radius: 5px;
    background-clip: padding-box;
    margin: 100px auto;
    width: 320px;
    padding: 35px 35px 15px 35px;
    background: #fff;
    border: 1px solid #eaeaea;
    box-shadow: 0 0 25px #cac6c6;
  }
  .title {
      margin: 0px auto 20px auto;
      text-align: center;
      color: #505458;
    }
</style>

</html>

這裡提供了一個受保護的介面,用於獲取使用者資訊,客戶端訪問這個介面的時候要求登入認證。

UserController.java

package com.louis.spring.oauth.server.controller;

import java.security.Principal;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class UserController {

    /**
     * 資源伺服器提供的受保護介面
     * @param principal
     * @return
     */
    @RequestMapping("/user")
    public Principal user(Principal principal) {
        System.out.println(principal);
        return principal;
    }
    
}

客戶端實現

新增依賴

主要新增 Spring Security 依賴,另外因為 Spring Boot 2.0 之後程式碼的合併, 需要新增 spring-security-oauth2-autoconfigure ,才能使用 @EnableOAuth2Sso 註解。

pom.xml

<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">
    <modelVersion>4.0.0</modelVersion>

    <artifactId>spring-oauth-client</artifactId>
    <name>spring-oauth-client</name>
    <packaging>war</packaging>

    <parent>
        <groupId>com.louis</groupId>
        <artifactId>spring-oauth-parent</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security.oauth.boot</groupId>
            <artifactId>spring-security-oauth2-autoconfigure</artifactId>
            <version>${oauth-auto.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.thymeleaf.extras</groupId>
            <artifactId>thymeleaf-extras-springsecurity4</artifactId>
        </dependency>
    </dependencies>

</project>

啟動類

啟動類需要新增 RequestContextListener,用於監聽HTTP請求事件。

OAuthClientApplication.java

package com.louis.spring.oauth.client;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
import org.springframework.context.annotation.Bean;
import org.springframework.web.context.request.RequestContextListener; @SpringBootApplication public class OAuthClientApplication extends SpringBootServletInitializer { @Bean public RequestContextListener requestContextListener() { return new RequestContextListener(); } public static void main(String[] args) { SpringApplication.run(OAuthClientApplication.class, args); } }

安全配置

新增安全配置類,新增 @EnableOAuth2Sso 註解支援單點登入。

OAuthClientSecurityConfig.java

package com.louis.spring.oauth.client.config;

import org.springframework.boot.autoconfigure.security.oauth2.client.EnableOAuth2Sso;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@EnableOAuth2Sso
@Configuration
public class OAuthClientSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
            .antMatcher("/**")
            .authorizeRequests()
            .antMatchers("/", "/login**")
            .permitAll()
            .anyRequest()
            .authenticated();
    }

}

頁面配置

新增 Spring MVC 配置,主要是新增 index 和 securedPage 頁面對應的訪問配置。

OAuthClientWebConfig.java

package com.louis.spring.oauth.client.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.PropertySourcesPlaceholderConfigurer;
import org.springframework.web.servlet.config.annotation.*;

@Configuration
@EnableWebMvc
public class OAuthClientWebConfig implements WebMvcConfigurer { @Bean public static PropertySourcesPlaceholderConfigurer propertySourcesPlaceholderConfigurer() { return new PropertySourcesPlaceholderConfigurer(); } @Override public void configureDefaultServletHandling(final DefaultServletHandlerConfigurer configurer) { configurer.enable(); } @Override public void addViewControllers(final ViewControllerRegistry registry) { registry.addViewController("/") .setViewName("forward:/index"); registry.addViewController("/index"); registry.addViewController("/securedPage"); } @Override public void addResourceHandlers(final ResourceHandlerRegistry registry) { registry.addResourceHandler("/resources/**") .addResourceLocations("/resources/"); } }

配置檔案

主要配置 oauth2 認證相關的配置。

application.yml

auth-server: http://localhost:8881/auth
server:
  port: 8882
  servlet:
    context-path: /
  session:
    cookie:
      name: SESSION1
security:
  basic:
    enabled: false
  oauth2:
    client:
      clientId: SampleClientId
      clientSecret: secret
      accessTokenUri: ${auth-server}/oauth/token
      userAuthorizationUri: ${auth-server}/oauth/authorize
    resource:
      userInfoUri: ${auth-server}/user
spring:
  thymeleaf:
    cache: false        

頁面檔案

頁面檔案只有兩個,index 是首頁,無須登入即可訪問,在首頁通過新增 login 按鈕訪問 securedPage 頁面,securedPage 訪問資源伺服器的 /user 介面獲取使用者資訊。

/resources/templates/index.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Spring Security SSO</title>
<link rel="stylesheet"
    href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css" />
</head>

<body>
<div class="container">
    <div class="col-sm-12">
        <h1>Spring Security SSO</h1>
        <a class="btn btn-primary" href="securedPage">Login</a>
    </div>
</div>
</body>
</html>

/resources/templates/securedPage.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Spring Security SSO</title>
<link rel="stylesheet"
    href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css" />
</head>

<body>
<div class="container">
    <div class="col-sm-12">
        <h1>Secured Page</h1>
        Welcome, <span th:text="${#authentication.name}">Name</span>
    </div>
</div>
</body>
</html>

spring-oauth-client2 內容跟 spring-oauth-client 基本一樣,除了埠為 8883 外,securedPage 顯示的內容稍微有點不一樣用於區分。

測試效果

啟動認證服務端和客戶端。

訪問 http://localhost:8882/,返回結果如下。

點選 login,跳轉到 securedPage 頁面,頁面呼叫資源伺服器的受保護介面 /user ,會跳轉到認證伺服器的登入介面,要求進行登入認證。

同理,訪問 http://localhost:8883/,返回結果如下。

點選 login,同樣跳轉到認證伺服器的登入介面,要求進行登入認證。

輸入使用者名稱密碼,預設是後臺配置的使用者資訊,使用者名稱:admin, 密碼:123 ,點選登入。

從 http://localhost:8882/ 發出的請求登入成功之後返回8882的安全保護頁面。

如果是從 http://localhost:8883/ 發出的登入請求,則會跳轉到8883的安全保護頁面。 

從 8882 發出登入請求,登入成功之後,訪問 http://localhost:8883/ ,點選登入。

結果不需要再進行登入,直接跳轉到了 8883 的安全保護頁面,因為在訪問 8882 的時候已經登入過了。

同理,假如先訪問 8883 資源進行登入之後,訪問 8882 也無需重複登入,到此,單點登入的案例實現就完成了。

執行流程剖析

接下來,針對上面的單點登入案例,我們對整個體系的執行流程進行詳細的剖析。

在此之前,我們先描述一下OAuth2授權碼模式的整個大致流程。

原理圖
1. 瀏覽器向UI伺服器點選觸發要求安全認證 
2. 跳轉到授權伺服器獲取授權許可碼 
3. 從授權伺服器帶授權許可碼跳回來 
4. UI伺服器向授權伺服器獲取AccessToken 
5. 返回AccessToken到UI伺服器 
6. 發出/resource請求到UI伺服器 
7. UI伺服器將/resource請求轉發到Resource伺服器 
8. Resource伺服器要求安全驗證,於是直接從授權伺服器獲取認證授權資訊進行判斷後(最後會響應給UI伺服器,UI伺服器再響應給瀏覽中器)

結合我們的案例,首先,我們通過 http://localhost:8882/,訪問 8882 的首頁,8883 同理。

然後點選 Login,重定向到了 http://localhost:8882/securedPage,而 securedPage 是受保護的頁面。所以就重定向到了 8882 的登入URL: http://localhost:8882/login, 要求首先進行登入認證。

因為客戶端配置了單點登入(@EnableOAuth2Sso),所以單點登入攔截器會讀取授權伺服器的配置,發起形如: http://localhost:8881/auth/oauth/authorize?client_id=SampleClientId&redirect_uri=http://localhost:8882/ui/login&response_type=code&state=xtDCY2 的授權請求獲取授權碼。

然後因為上面訪問的是認證伺服器的資源,所以又重定向到了認證伺服器的登入URL: http://localhost:8881/auth/login,也就是我們自定義的統一認證登入平臺頁面,要求先進行登入認證,然後才能繼續傳送獲取授權碼的請求。

我們輸入使用者名稱和密碼,點選登入按鈕進行登入認證。

登入認證的大致流程如下:

AbstractAuthenticationProcessingFilter.doFilter()

預設的登入過濾器 UsernamePasswordAuthenticationFilter 攔截到登入請求,呼叫父類的 doFilter 的方法。

    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
     ...

        Authentication authResult;
        try {
            authResult = attemptAuthentication(request, response);
            if (authResult == null) {
                // return immediately as subclass has indicated that it hasn't completed
                // authentication
                return;
            }
            sessionStrategy.onAuthentication(authResult, request, response);
        }
        ... successfulAuthentication(request, response, chain, authResult); }

UsernamePasswordAuthenticationFilter.attemptAuthentication()

doFilter 方法呼叫 UsernamePasswordAuthenticationFilter 自身的 attemptAuthentication 方法進行登入認證。

    public Authentication attemptAuthentication(HttpServletRequest request,
            HttpServletResponse response) throws AuthenticationException {
     ...
String username = obtainUsername(request); String password = obtainPassword(request); UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password); // Allow subclasses to set the "details" property setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest); }

ProviderManager.authenticate()

attemptAuthentication 繼續呼叫認證管理器 ProviderManager 的 authenticate 方法。

    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        Class<? extends Authentication> toTest = authentication.getClass();
        AuthenticationException lastException = null;
        Authentication result = null;
        boolean debug = logger.isDebugEnabled(); for (AuthenticationProvider provider : getProviders()) { if (!provider.supports(toTest)) { continue; }try { result = provider.authenticate(authentication); if (result != null) { copyDetails(authentication, result); break; } }       ... } }

AbstractUserDetailsAuthenticationProvider.authenticate()

而 ProviderManager 又是通過一組 AuthenticationProvider 來完成登入認證的,其中的預設實現是 DaoAuthenticationProvider,繼承自 AbstractUserDetailsAuthenticationProvider, 所以 AbstractUserDetailsAuthenticationProvider 的 authenticate 方法被呼叫。

    public Authentication authenticate(Authentication authentication) throws AuthenticationException {// Determine username
        String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED" : authentication.getName();
        boolean cacheWasUsed = true;
        UserDetails user = this.userCache.getUserFromCache(username);
        if (user == null) { cacheWasUsed = false; try { user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); } ... } try { preAuthenticationChecks.check(user); additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); } ...return createSuccessAuthentication(principalToReturn, authentication, user); }

DaoAuthenticationProvider.retrieveUser()

AbstractUserDetailsAuthenticationProvider 的 authenticate 在認證過程中又呼叫 DaoAuthenticationProvider 的 retrieveUser 方法獲取登入認證所需的使用者資訊。

    protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
            throws AuthenticationException {
        prepareTimingAttackProtection();
        try {
            UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);return loadedUser;
        }
        ...
    }

UserDetailsManager.loadUserByUsername()

DaoAuthenticationProvider 的 retrieveUser 方法 通過 UserDetailsService 來進一步獲取登入認證所需的使用者資訊。UserDetailsManager 介面繼承了 UserDetailsService 介面,框架預設提供了 InMemoryUserDetailsManager 和 JdbcUserDetailsManager 兩種使用者資訊的獲取方式,當然 InMemoryUserDetailsManager 主要用於非正式環境,正式環境大多都是採用  JdbcUserDetailsManager,從資料庫獲取使用者資訊,當然你也可以根據需要擴充套件其他的獲取方式。

DaoAuthenticationProvider 的大致實現:

    @Override
    public UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException {
        List<UserDetails> users = loadUsersByUsername(username);

        UserDetails user = users.get(0); // contains no GrantedAuthority[]
 Set<GrantedAuthority> dbAuthsSet = new HashSet<>(); ...
List<GrantedAuthority> dbAuths = new ArrayList<>(dbAuthsSet); addCustomAuthorities(user.getUsername(), dbAuths);return createUserDetails(username, user, dbAuths); }

InMemoryUserDetailsManager 的大致實現:

    public UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException {
        UserDetails user = users.get(username.toLowerCase());

        if (user == null) {
            throw new UsernameNotFoundException(username);
        }

        return new User(user.getUsername(), user.getPassword(), user.isEnabled(), user.isAccountNonExpired(), user.isCredentialsNonExpired(), user.isAccountNonLocked(), user.getAuthorities()); }

DaoAuthenticationProvider.additionalAuthenticationChecks()

獲取到使用者認證所需的資訊之後,認證器會進行一些檢查譬如 preAuthenticationChecks 進行賬號狀態之類的前置檢查,然後呼叫 DaoAuthenticationProvider 的 additionalAuthenticationChecks 方法驗證密碼合法性。

    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        // Determine username
        String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED" : authentication.getName();
        boolean cacheWasUsed = true;
        UserDetails user = this.userCache.getUserFromCache(username);
        if (user == null) {
            cacheWasUsed = false; try { user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); } ... } try {  preAuthenticationChecks.check(user); additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); } ... return createSuccessAuthentication(principalToReturn, authentication, user); }

AbstractUserDetailsAuthenticationProvider.createSuccessAuthentication()

登入認證成功之後, AbstractUserDetailsAuthenticationProvider 的 createSuccessAuthentication 方法被呼叫, 返回一個 UsernamePasswordAuthenticationToken 物件。

    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        // Determine username
        String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED" : authentication.getName();
        boolean cacheWasUsed = true;
        UserDetails user = this.userCache.getUserFromCache(username);
        if (user == null) {
            cacheWasUsed = false; try { user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); } ... } try { preAuthenticationChecks.check(user); additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); } ... return createSuccessAuthentication(principalToReturn, authentication, user); }

AbstractAuthenticationProcessingFilter.successfulAuthentication()

認證成功之後,繼續回到 AbstractAuthenticationProcessingFilter,執行 successfulAuthentication 方法,存放認證資訊到上下文,最終決定登入認證成功之後的操作。

    protected void successfulAuthentication(HttpServletRequest request,
            HttpServletResponse response, FilterChain chain, Authentication authResult)
            throws IOException, ServletException {

     // 將登入認證資訊放置到上下文,在授權階段從上下文獲取 SecurityContextHolder.getContext().setAuthentication(authResult); rememberMeServices.loginSuccess(request, response, authResult); // Fire event if (this.eventPublisher != null) { eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent( authResult, this.getClass())); } successHandler.onAuthenticationSuccess(request, response, authResult); }

SavedRequestAwareAuthenticationSuccessHandler.onAuthenticationSuccess()

登入成功之後,呼叫 SavedRequestAwareAuthenticationSuccessHandler 的 onAuthenticationSuccess 方法,最後根據配置再次傳送授權請求 :

http://localhost:8881/auth/oauth/authorize?client_id=SampleClientId&redirect_uri=http://localhost:8882/login&response_type=code&state=xtDCY2

AuthorizationEndpoint.authorize()

根據路徑匹配 /oauth/authorize,AuthorizationEndpoint 的 authorize 介面被呼叫。

    @RequestMapping(value = "/oauth/authorize")
    public ModelAndView authorize(Map<String, Object> model, @RequestParam Map<String, String> parameters,
            SessionStatus sessionStatus, Principal principal) {

        AuthorizationRequest authorizationRequest = getOAuth2RequestFactory().createAuthorizationRequest(parameters);

        Set<String> responseTypes = authorizationRequest.getResponseTypes();try { ClientDetails client = getClientDetailsService().loadClientByClientId(authorizationRequest.getClientId()); // The resolved redirect URI is either the redirect_uri from the parameters or the one from // clientDetails. Either way we need to store it on the AuthorizationRequest. String redirectUriParameter = authorizationRequest.getRequestParameters().get(OAuth2Utils.REDIRECT_URI); String resolvedRedirect = redirectResolver.resolveRedirect(redirectUriParameter, client); authorizationRequest.setRedirectUri(resolvedRedirect); // We intentionally only validate the parameters requested by the client (ignoring any data that may have // been added to the request by the manager).  oauth2RequestValidator.validateScope(authorizationRequest, client); // Some systems may allow for approval decisions to be remembered or approved by default. Check for // such logic here, and set the approved flag on the authorization request accordingly. authorizationRequest = userApprovalHandler.checkForPreApproval(authorizationRequest, (Authentication) principal); // TODO: is this call necessary? boolean approved = userApprovalHandler.isApproved(authorizationRequest, (Authentication) principal); authorizationRequest.setApproved(approved); // Validation is all done, so we can check for auto approval... if (authorizationRequest.isApproved()) { if (responseTypes.contains("token")) { return getImplicitGrantResponse(authorizationRequest); } if (responseTypes.contains("code")) { return new ModelAndView(getAuthorizationCodeResponse(authorizationRequest, (Authentication) principal)); } } // Store authorizationRequest AND an immutable Map of authorizationRequest in session // which will be used to validate against in approveOrDeny()  model.put(AUTHORIZATION_REQUEST_ATTR_NAME, authorizationRequest); model.put(ORIGINAL_AUTHORIZATION_REQUEST_ATTR_NAME, unmodifiableMap(authorizationRequest)); return getUserApprovalPageResponse(model, authorizationRequest, (Authentication) principal); } }

DefaultOAuth2RequestFactory.createAuthorizationRequest()

DefaultOAuth2RequestFactory 的 createAuthorizationRequest 方法被呼叫,用來建立 AuthorizationRequest。

    public AuthorizationRequest createAuthorizationRequest(Map<String, String> authorizationParameters) {
     // 構造 AuthorizationRequest
        String clientId = authorizationParameters.get(OAuth2Utils.CLIENT_ID);
        String state = authorizationParameters.get(OAuth2Utils.STATE);
        String redirectUri = authorizationParameters.get(OAuth2Utils.REDIRECT_URI);
        Set<String> responseTypes = OAuth2Utils.parseParameterList(authorizationParameters.get(OAuth2Utils.RESPONSE_TYPE));
        Set<String> scopes = extractScopes(authorizationParameters, clientId); AuthorizationRequest request = new AuthorizationRequest(authorizationParameters, Collections.<String, String> emptyMap(), clientId, scopes, null, null, false, state, redirectUri, responseTypes);      // 通過 ClientDetailsService 載入 ClientDetails ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientId); request.setResourceIdsAndAuthoritiesFromClientDetails(clientDetails); return request; }

ClientDetailsService.loadClientByClientId()

ClientDetailsService 的 loadClientByClientId 方法被呼叫,框架提供了 ClientDetailsService 的兩種實現 InMemoryClientDetailsService 和 JdbcClientDetailsService,分別對應從記憶體獲取和從資料庫獲取,當然你也可以根據需要定製其他獲取方式。

JdbcClientDetailsService 的大致實現,主要是通過 JdbcTemplate 獲取,需要設定一個 datasource。

    public ClientDetails loadClientByClientId(String clientId) throws InvalidClientException {
        ClientDetails details;
        try {
            details = jdbcTemplate.queryForObject(selectClientDetailsSql, new ClientDetailsRowMapper(), clientId);
        }
        catch (EmptyResultDataAccessException e) {
            throw new NoSuchClientException("No client with requested id: " + clientId);
        }

        return details; }

InMemoryClientDetailsService 的大致實現,主要是從記憶體Store裡面取出資訊。

  public ClientDetails loadClientByClientId(String clientId) throws ClientRegistrationException {
    ClientDetails details = clientDetailsStore.get(clientId);
    if (details == null) {
      throw new NoSuchClientException("No client with requested id: " + clientId);
    }
    return details;
  }

AuthorizationEndpoint.authorize()

繼續回到 AuthorizationEndpoint 的 authorize 方法

    @RequestMapping(value = "/oauth/authorize")
    public ModelAndView authorize(Map<String, Object> model, @RequestParam Map<String, String> parameters,
            SessionStatus sessionStatus, Principal principal) {
        AuthorizationRequest authorizationRequest = getOAuth2RequestFactory().createAuthorizationRequest(parameters);
        Set<String> responseTypes = authorizationRequest.getResponseTypes();try {         // 建立ClientDtails ClientDetails client = getClientDetailsService().loadClientByClientId(authorizationRequest.getClientId()); // The resolved redirect URI is either the redirect_uri from the parameters or the one from // 設定跳轉URL String redirectUriParameter = authorizationRequest.getRequestParameters().get(OAuth2Utils.REDIRECT_URI); String resolvedRedirect = redirectResolver.resolveRedirect(redirectUriParameter, client); authorizationRequest.setRedirectUri(resolvedRedirect); // 驗證授權範圍  oauth2RequestValidator.validateScope(authorizationRequest, client); // 檢查是否是自動完成授權還是轉到授權頁面讓使用者手動確認 authorizationRequest = userApprovalHandler.checkForPreApproval(authorizationRequest, (Authentication) principal); // TODO: is this call necessary? boolean approved = userApprovalHandler.isApproved(authorizationRequest, (Authentication) principal); authorizationRequest.setApproved(approved); // Validation is all done, so we can check for auto approval... if (authorizationRequest.isApproved()) {
         if (responseTypes.contains("token")) { return getImplicitGrantResponse(authorizationRequest); } if (responseTypes.contains("code")) {
            // 如果是授權碼模式,且為自動授權或已完成授權,直接返回授權結果 return new ModelAndView(getAuthorizationCodeResponse(authorizationRequest, (Authentication) principal)); } } // Store authorizationRequest AND an immutable Map of authorizationRequest in session // which will be used to validate against in approveOrDeny() model.put(AUTHORIZATION_REQUEST_ATTR_NAME, authorizationRequest); model.put(ORIGINAL_AUTHORIZATION_REQUEST_ATTR_NAME, unmodifiableMap(authorizationRequest)); return getUserApprovalPageResponse(model, authorizationRequest, (Authentication) principal); } }

如果是需要手動授權,轉到授權頁面URL: /oauth/confirm_access 。

    private ModelAndView getUserApprovalPageResponse(Map<String, Object> model,
            AuthorizationRequest authorizationRequest, Authentication principal) {
        if (logger.isDebugEnabled()) {
            logger.debug("Loading user approval page: " + userApprovalPage);
        }
        model.putAll(userApprovalHandler.getUserApprovalRequest(authorizationRequest, principal));
     // 轉到授權頁面, URL /oauth/confirm_access  return new ModelAndView(userApprovalPage, model); }

 使用者手動授權頁面

AuthorizationEndpoint.approveOrDeny()

AuthorizationEndpoint 中 POST 請求的介面 /oauth/authorize 對應的 approveOrDeny 方法被呼叫 。

    @RequestMapping(value = "/oauth/authorize", method = RequestMethod.POST, params = OAuth2Utils.USER_OAUTH_APPROVAL)
    public View approveOrDeny(@RequestParam Map<String, String> approvalParameters, Map<String, ?> model,
            SessionStatus sessionStatus, Principal principal) {

        AuthorizationRequest authorizationRequest = (AuthorizationRequest) model.get(AUTHORIZATION_REQUEST_ATTR_NAME);
     try {
            Set<String> responseTypes = authorizationRequest.getResponseTypes(); authorizationRequest.setApprovalParameters(approvalParameters); authorizationRequest = userApprovalHandler.updateAfterApproval(authorizationRequest, (Authentication) principal); boolean approved = userApprovalHandler.isApproved(authorizationRequest, (Authentication) principal); authorizationRequest.setApproved(approved);        if (!authorizationRequest.isApproved()) {
          // 使用者不許授權,拒絕訪問 return new RedirectView(getUnsuccessfulRedirect(authorizationRequest, new UserDeniedAuthorizationException("User denied access"), responseTypes.contains("token")), false, true, false); }         // 使用者授權完成,跳轉到客戶端設定的重定向URL return getAuthorizationCodeResponse(authorizationRequest, (Authentication) principal); } }

使用者授權完成,跳轉到客戶端設定的重定向URL。

 

BasicAuthenticationFilter.doFilterInternal()

轉到客戶端重定向URL之後,BasicAuthenticationFilter 攔截到請求, doFilterInternal 方法被呼叫,攜帶資訊在客戶端執行登入認證。

  @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
                    throws IOException, ServletException {
        String header = request.getHeader("Authorization");
     try { String[] tokens = extractAndDecodeHeader(header, request); assert tokens.length == 2; String username = tokens[0];
      if (authenticationIsRequired(username)) { UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, tokens[1]); authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
          Authentication authResult = this.authenticationManager.authenticate(authRequest); SecurityContextHolder.getContext().setAuthentication(authResult); this.rememberMeServices.loginSuccess(request, response, authResult); onSuccessfulAuthentication(request, response, authResult); } } chain.doFilter(request, response); }

如上面程式碼顯示,doFilterInternal 方法中客戶端登入認證邏輯也走了一遍,詳細過程跟上面授權服務端的認證過程一般無二,這裡就不貼重複程式碼,大致流程如下連結流所示:

ProviderManager.authenticate() -- > AbstractUserDetailsAuthenticationProvider.authenticate() --> DaoAuthenticationProvider.retrieveUser() --> ClientDetailsUserDetailsService.loadUserByUsername() --> AbstractUserDetailsAuthenticationProvider.createSuccessAuthentication()

TokenEndpoint.postAccessToken()

認證成功之後,客戶端獲取了許可權憑證,返回客戶端URL,被 OAuth2ClientAuthenticationProcessingFilter 攔截,然後攜帶授權憑證向授權伺服器發起形如: http://localhost:8881/auth/oauth/token 的 Post 請求換取訪問 token,對應的是授權伺服器的 TokenEndpoint 類的 postAccessToken 方法。

    @RequestMapping(value = "/oauth/token", method=RequestMethod.POST)
    public ResponseEntity<OAuth2AccessToken> postAccessToken(Principal principal, @RequestParam
    Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {
     // 獲取之前的請求資訊,並對token獲取請求資訊進行校驗
        String clientId = getClientId(principal);
        ClientDetails authenticatedClient = getClientDetailsService().loadClientByClientId(clientId); TokenRequest tokenRequest = getOAuth2RequestFactory().createTokenRequest(parameters, authenticatedClient);if (authenticatedClient != null) { oAuth2RequestValidator.validateScope(tokenRequest, authenticatedClient); } if (!StringUtils.hasText(tokenRequest.getGrantType())) { throw new InvalidRequestException("Missing grant type"); } if (tokenRequest.getGrantType().equals("implicit")) { throw new InvalidGrantException("Implicit grant type not supported from token endpoint"); } ... 
     // 生成 token 並返回給客戶端,客戶端就可攜帶此 token 向資源伺服器獲取資訊了 OAuth2AccessToken token = getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest);return getResponse(token); }

TokenGranter.grant()

令牌的生成通過 TokenGranter 的 grant 方法來完成。根據授權方式的型別,分別有對應的 TokenGranter 實現,如我們使用的授權碼模式,對應的是 AuthorizationCodeTokenGranter。

AbstractTokenGranter.grant()

AuthorizationCodeTokenGranter 的父類 AbstractTokenGranter 的 grant 方法被呼叫。

    public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {

        if (!this.grantType.equals(grantType)) {
            return null;
        }
        
        String clientId = tokenRequest.getClientId();
        ClientDetails client = clientDetailsService.loadClientByClientId(clientId);
        validateGrantType(grantType, client);

        if (logger.isDebugEnabled()) { logger.debug("Getting access token for: " + clientId); }  return getAccessToken(client, tokenRequest); } protected OAuth2AccessToken getAccessToken(ClientDetails client, TokenRequest tokenRequest) { return tokenServices.createAccessToken(getOAuth2Authentication(client, tokenRequest)); }

DefaultTokenServices.createAccessToken()

DefaultTokenServices 的 createAccessToken 被呼叫,用來生成 token。

  @Transactional
    public OAuth2AccessToken createAccessToken(OAuth2Authentication authentication) throws AuthenticationException {
     // 先從 Store 獲取,Sotre 型別有 InMemoryTokenStore、JdbcTokenStore、JwtTokenStore、RedisTokenStore 等
        OAuth2AccessToken existingAccessToken = tokenStore.getAccessToken(authentication);
        OAuth2RefreshToken refreshToken = null;
        if (existingAccessToken != null) {
            if (existingAccessToken.isExpired()) { if (existingAccessToken.getRefreshToken() != null) { refreshToken = existingAccessToken.getRefreshToken(); // The token store could remove the refresh token when the // access token is removed, but we want to be sure...  tokenStore.removeRefreshToken(refreshToken); } tokenStore.removeAccessToken(existingAccessToken); } else { // Re-store the access token in case the authentication has changed  tokenStore.storeAccessToken(existingAccessToken, authentication); return existingAccessToken; } } // Only create a new refresh token if there wasn't an existing one associated with an expired access token. // Clients might be holding existing refresh tokens, so we re-use it in the case that the old access token expired. if (refreshToken == null) { refreshToken = createRefreshToken(authentication); } // But the refresh token itself might need to be re-issued if it has expired. else if (refreshToken instanceof ExpiringOAuth2RefreshToken) { ExpiringOAuth2RefreshToken expiring = (ExpiringOAuth2RefreshToken) refreshToken; if (System.currentTimeMillis() > expiring.getExpiration().getTime()) { refreshToken = createRefreshToken(authentication); } } OAuth2AccessToken accessToken = createAccessToken(authentication, refreshToken); tokenStore.storeAccessToken(accessToken, authentication); // In case it was modified refreshToken = accessToken.getRefreshToken(); if (refreshToken != null) { tokenStore.storeRefreshToken(refreshToken, authentication); } return accessToken; }
private OAuth2AccessToken createAccessToken(OAuth2Authentication authentication, OAuth2RefreshToken refreshToken) { DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken(UUID.randomUUID().toString()); int validitySeconds = getAccessTokenValiditySeconds(authentication.getOAuth2Request()); if (validitySeconds > 0) { token.setExpiration(new Date(System.currentTimeMillis() + (validitySeconds * 1000L))); } token.setRefreshToken(refreshToken); token.setScope(authentication.getOAuth2Request().getScope()); return accessTokenEnhancer != null ? accessTokenEnhancer.enhance(token, authentication) : token; }

客戶端攜帶Token訪問資源

token 被生成後返回給了客戶端,客戶端攜帶此 token 發起形如: http://localhost:8881/auth/user 的請求獲取使用者資訊。

OAuth2AuthenticationProcessingFilter 過濾器攔截請求,然後呼叫 OAuth2AuthenticationManager 的 authenticate 方法執行登入流程。

OAuth2AuthenticationProcessingFilter.doFilter()

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException,
            ServletException {

        final boolean debug = logger.isDebugEnabled();
        final HttpServletRequest request = (HttpServletRequest) req;
        final HttpServletResponse response = (HttpServletResponse) res;

        try {
       // 獲取並校驗 token 之後,然後攜帶 token 進行登入 
            Authentication authentication = tokenExtractor.extract(request); ...
      else { request.setAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_VALUE, authentication.getPrincipal()); if (authentication instanceof AbstractAuthenticationToken) { AbstractAuthenticationToken needsDetails = (AbstractAuthenticationToken) authentication; needsDetails.setDetails(authenticationDetailsSource.buildDetails(request)); }
           Authentication authResult = authenticationManager.authenticate(authentication); if (debug) { logger.debug("Authentication success: " + authResult); } eventPublisher.publishAuthenticationSuccess(authResult); SecurityContextHolder.getContext().setAuthentication(authResult); } } chain.doFilter(request, response); }

OAuth2AuthenticationManager.authenticate()

OAuth2AuthenticationManager 的 authenticate 方法被呼叫,利用 token 執行登入認證。

    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        if (authentication == null) {
            throw new InvalidTokenException("Invalid token (token not found)");
        }
        String token = (String) authentication.getPrincipal();
        OAuth2Authentication auth = tokenServices.loadAuthentication(token);
        if (auth == null) { throw new InvalidTokenException("Invalid token: " + token); } Collection<String> resourceIds = auth.getOAuth2Request().getResourceIds(); if (resourceId != null && resourceIds != null && !resourceIds.isEmpty() && !resourceIds.contains(resourceId)) { throw new OAuth2AccessDeniedException("Invalid token does not contain resource id (" + resourceId + ")"); } checkClientDetails(auth); if (authentication.getDetails() instanceof OAuth2AuthenticationDetails) { OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) authentication.getDetails(); // Guard against a cached copy of the same details if (!details.equals(auth.getDetails())) { // Preserve the authentication details from the one loaded by token services  details.setDecodedDetails(auth.getDetails()); } } auth.setDetails(authentication.getDetails()); auth.setAuthenticated(true); return auth; }

認證成功之後,獲取目標介面資料,然後重定向了真正的訪問目標URL  http://localhost:8882/securedPage,並資訊獲取的資料資訊。

訪問 http://localhost:8882/securedPage,返回結果如下:

訪問 http://localhost:8883/securedPage,返回結果如下:

另外,在客戶端訪問受保護的資源的時候,會被 OAuth2ClientAuthenticationProcessingFilter 過濾器攔截。

OAuth2ClientAuthenticationProcessingFilter  的主要作用是獲取 token 進行登入認證。

此時可能會出現以下幾種情況:

1. 獲取不到之前儲存的 token,或者 token 已經過期,此時會繼續判斷請求中是否攜帶從認證伺服器獲取的授權