1. 程式人生 > >Spring Boot:整合Spring Security

Spring Boot:整合Spring Security

綜合概述

Spring Security 是 Spring 社群的一個頂級專案,也是 Spring Boot 官方推薦使用的安全框架。除了常規的認證(Authentication)和授權(Authorization)之外,Spring Security還提供了諸如ACLs,LDAP,JAAS,CAS等高階特性以滿足複雜場景下的安全需求。另外,就目前而言,Spring Security和Shiro也是當前廣大應用使用比較廣泛的兩個安全框架。

Spring Security 應用級別的安全主要包含兩個主要部分,即登入認證(Authentication)和訪問授權(Authorization),首先使用者登入的時候傳入登入資訊,登入驗證器完成登入認證並將登入認證好的資訊儲存到請求上下文,然後再進行其他操作,如在進行介面訪問、方法呼叫時,許可權認證器從上下文中獲取登入認證資訊,然後根據認證資訊獲取許可權資訊,通過許可權資訊和特定的授權策略決定是否授權。

本教程將首先給出一個完整的案例實現,然後再分別對登入認證和訪問授權的執行流程進行剖析,希望大家可以通過實現案例和流程分析,充分理解Spring Security的登入認證和訪問授權的執行原理,並且能夠在理解原理的基礎上熟練自主的使用Spring Security實現相關的需求。

實現案例

接下來,我們就通過一個具體的案例,來講解如何進行Spring Security的整合,然後藉助Spring Security實現登入認證和訪問控制。

生成專案模板

為方便我們初始化專案,Spring Boot給我們提供一個專案模板生成網站。

1.  開啟瀏覽器,訪問:https://start.spring.io/

2.  根據頁面提示,選擇構建工具,開發語言,專案資訊等。

3.  點選 Generate the project,生成專案模板,生成之後會將壓縮包下載到本地。

4.  使用IDE匯入專案,我這裡使用Eclipse,通過匯入Maven專案的方式匯入。

新增相關依賴

清理掉不需要的測試類及測試依賴,新增 Maven 相關依賴,這裡需要新增上web、swagger、spring security、jwt和fastjson的依賴,Swagge和fastjson的新增是為了方便介面測試。

pom.xml

<?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">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.5.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.louis.springboot</groupId>
    <artifactId>demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>demo</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <!-- web -->
        <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-web</artifactId>
         </dependency>
        <!-- swagger -->
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
            <version>2.9.2</version>
        </dependency>
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
            <version>2.9.2</version>
        </dependency>
        <!-- spring security -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <!-- jwt -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>
        <!-- fastjson -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.58</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
        <!-- 打包時拷貝MyBatis的對映檔案 -->
        <resources>
            <resource>
                <directory>src/main/java</directory>
                <includes>
                    <include>**/sqlmap/*.xml</include>
                </includes>
                <filtering>false</filtering>
            </resource>
            <resource>  
                <directory>src/main/resources</directory>  
                    <includes> 
                        <include>**/*.*</include>  
                    </includes> 
                    <filtering>true</filtering>  
            </resource> 
        </resources>
    </build>

</project>

新增相關配置

1.新增swagger 配置

新增一個swagger 配置類,在工程下新建 config 包並新增一個 SwaggerConfig 配置類,除了常規配置外,加了一個令牌屬性,可以在介面呼叫的時候傳遞令牌。

SwaggerConfig.java

package com.louis.springboot.demo.config;
import java.util.ArrayList;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.ParameterBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.schema.ModelRef;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Parameter;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

@Configuration
@EnableSwagger2
public class SwaggerConfig {

    @Bean
    public Docket createRestApi(){
        // 新增請求引數,我們這裡把token作為請求頭部引數傳入後端
        ParameterBuilder parameterBuilder = new ParameterBuilder();
        List<Parameter> parameters = new ArrayList<Parameter>();
        parameterBuilder.name("Authorization").description("令牌").modelRef(new ModelRef("string")).parameterType("header")
                .required(false).build();
        parameters.add(parameterBuilder.build());
        return new Docket(DocumentationType.SWAGGER_2).apiInfo(apiInfo()).select().apis(RequestHandlerSelectors.any())
                .paths(PathSelectors.any()).build().globalOperationParameters(parameters);
    }

    private ApiInfo apiInfo(){
        return new ApiInfoBuilder()
                .title("SpringBoot API Doc")
                .description("This is a restful api document of Spring Boot.")
                .version("1.0")
                .build();
    }

}

加了令牌屬性後的 Swagger 介面呼叫介面,會多出一個令牌引數,在發起請求的時候一起傳送令牌。

2.新增跨域 配置

新增一個CORS跨域配置類,在工程下新建 config 包並新增一個 CorsConfig配置類。

CorsConfig.java

package com.louis.springboot.demo.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")    // 允許跨域訪問的路徑
        .allowedOrigins("*")    // 允許跨域訪問的源
        .allowedMethods("POST", "GET", "PUT", "OPTIONS", "DELETE")    // 允許請求方法
        .maxAge(168000)    // 預檢間隔時間
        .allowedHeaders("*")  // 允許頭部設定
        .allowCredentials(true);    // 是否傳送cookie
    }
}

安全配置類

下面這個配置類是Spring Security的關鍵配置。

在這個配置類中,我們主要做了以下幾個配置:

1. 訪問路徑URL的授權策略,如登入、Swagger訪問免登入認證等

2. 指定了登入認證流程過濾器 JwtLoginFilter,由它來觸發登入認證

3. 指定了自定義身份認證元件 JwtAuthenticationProvider,並注入 UserDetailsService

4. 指定了訪問控制過濾器 JwtAuthenticationFilter,在授權時解析令牌和設定登入狀態

5. 指定了退出登入處理器,因為是前後端分離,防止內建的登入處理器在後臺進行跳轉

WebSecurityConfig.java

package com.louis.springboot.demo.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler;

import com.louis.springboot.demo.security.JwtAuthenticationFilter;
import com.louis.springboot.demo.security.JwtAuthenticationProvider;
import com.louis.springboot.demo.security.JwtLoginFilter;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;
    
    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 使用自定義登入身份認證元件
        auth.authenticationProvider(new JwtAuthenticationProvider(userDetailsService));
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 禁用 csrf, 由於使用的是JWT,我們這裡不需要csrf
        http.cors().and().csrf().disable()
            .authorizeRequests()
            // 跨域預檢請求
            .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
            // 登入URL
            .antMatchers("/login").permitAll()
            // swagger
            .antMatchers("/swagger**/**").permitAll()
            .antMatchers("/webjars/**").permitAll()
            .antMatchers("/v2/**").permitAll()
            // 其他所有請求需要身份認證
            .anyRequest().authenticated();
        // 退出登入處理器
        http.logout().logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler());
        // 開啟登入認證流程過濾器
        http.addFilterBefore(new JwtLoginFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class);
        // 訪問控制時登入狀態檢查過濾器
        http.addFilterBefore(new JwtAuthenticationFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class);
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }
    
}

登入認證觸發過濾器

JwtLoginFilter 是在通過訪問 /login 的POST請求是被首先被觸發的過濾器,預設實現是 UsernamePasswordAuthenticationFilter,它繼承了 AbstractAuthenticationProcessingFilter,抽象父類的 doFilter 定義了登入認證的大致操作流程,這裡我們的 JwtLoginFilter 繼承了 UsernamePasswordAuthenticationFilter,並進行了兩個主要內容的定製。

1. 覆寫認證方法,修改使用者名稱、密碼的獲取方式,具體原因看程式碼註釋

2. 覆寫認證成功後的操作,移除後臺跳轉,新增生成令牌並返回給客戶端

JwtLoginFilter.java

package com.louis.springboot.demo.security;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.Charset;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.event.InteractiveAuthenticationSuccessEvent;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.louis.springboot.demo.utils.HttpUtils;
import com.louis.springboot.demo.utils.JwtTokenUtils;

/**
 * 啟動登入認證流程過濾器
 * @author Louis
 * @date Jun 29, 2019
 */
public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter {
    
    public JwtLoginFilter(AuthenticationManager authManager) {
        setAuthenticationManager(authManager);
    }
    
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        // POST 請求 /login 登入時攔截, 由此方法觸發執行登入認證流程,可以在此覆寫整個登入認證邏輯
        super.doFilter(req, res, chain); 
    }
    
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        // 可以在此覆寫嘗試進行登入認證的邏輯,登入成功之後等操作不再此方法內
        // 如果使用此過濾器來觸發登入認證流程,注意登入請求資料格式的問題
        // 此過濾器的使用者名稱密碼預設從request.getParameter()獲取,但是這種
        // 讀取方式不能讀取到如 application/json 等 post 請求資料,需要把
        // 使用者名稱密碼的讀取邏輯修改為到流中讀取request.getInputStream()

        String body = getBody(request);
        JSONObject jsonObject = JSON.parseObject(body);
        String username = jsonObject.getString("username");
        String password = jsonObject.getString("password");

        if (username == null) {
            username = "";
        }

        if (password == null) {
            password = "";
        }

        username = username.trim();

        JwtAuthenticatioToken authRequest = new JwtAuthenticatioToken(username, password);

        // Allow subclasses to set the "details" property
        setDetails(request, authRequest);

        return this.getAuthenticationManager().authenticate(authRequest);
    
    }
    
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
            Authentication authResult) throws IOException, ServletException {
        // 儲存登入認證資訊到上下文
        SecurityContextHolder.getContext().setAuthentication(authResult);
        // 記住我服務
        getRememberMeServices().loginSuccess(request, response, authResult);
        // 觸發事件監聽器
        if (this.eventPublisher != null) {
            eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
        }
        // 生成並返回token給客戶端,後續訪問攜帶此token
        JwtAuthenticatioToken token = new JwtAuthenticatioToken(null, null, JwtTokenUtils.generateToken(authResult));
        HttpUtils.write(response, token);
    }
    
    /** 
     * 獲取請求Body
     * @param request
     * @return
     */
    public String getBody(HttpServletRequest request) {
        StringBuilder sb = new StringBuilder();
        InputStream inputStream = null;
        BufferedReader reader = null;
        try {
            inputStream = request.getInputStream();
            reader = new BufferedReader(new InputStreamReader(inputStream, Charset.forName("UTF-8")));
            String line = "";
            while ((line = reader.readLine()) != null) {
                sb.append(line);
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (inputStream != null) {
                try {
                    inputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if (reader != null) {
                try {
                    reader.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        return sb.toString();
    }
}

登入控制器

除了使用上面的登入認證過濾器攔截 /login Post請求之外,我們也可以不使用上面的過濾器,通過自定義登入介面實現,只要在登入介面手動觸發登入流程並生產令牌即可。

其實 Spring Security 的登入認證過程只需呼叫 AuthenticationManager 的 authenticate(Authentication authentication) 方法,最終返回認證成功的 Authentication 實現類並存儲到SpringContexHolder 上下文即可,這樣後面授權的時候就可以從 SpringContexHolder 中獲取登入認證資訊,並根據其中的使用者資訊和許可權資訊決定是否進行授權。

LoginController.java

package com.louis.springboot.demo.controller;
import java.io.IOException;

import javax.servlet.http.HttpServletRequest;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import com.louis.springboot.demo.security.JwtAuthenticatioToken;
import com.louis.springboot.demo.utils.SecurityUtils;
import com.louis.springboot.demo.vo.HttpResult;
import com.louis.springboot.demo.vo.LoginBean;

/**
 * 登入控制器
 * @author Louis
 * @date Jun 29, 2019
 */
@RestController
public class LoginController {

    @Autowired
    private AuthenticationManager authenticationManager;

    /**
     * 登入介面
     */
    @PostMapping(value = "/login")
    public HttpResult login(@RequestBody LoginBean loginBean, HttpServletRequest request) throws IOException {
        String username = loginBean.getUsername();
        String password = loginBean.getPassword();
        
        // 系統登入認證
        JwtAuthenticatioToken token = SecurityUtils.login(request, username, password, authenticationManager);
                
        return HttpResult.ok(token);
    }

}

注意:如果使用此登入控制器觸發登入認證,需要禁用登入認證過濾器,即將 WebSecurityConfig 中的以下配置項註釋即可,否則訪問登入介面會被過濾攔截,執行不會再進入此登入介面,大家根據使用習慣二選一即可。

// 開啟登入認證流程過濾器,如果使用LoginController的login介面, 需要註釋掉此過濾器,根據使用習慣二選一即可
http.addFilterBefore(new JwtLoginFilter(authenticationManager()), UsernamePasswordAuthenticationFilter.class);

如下是登入認證的邏輯, 可以看到部分邏輯跟上面的登入認證過濾器差不多。

1. 執行登入認證過程,通過呼叫 AuthenticationManager 的 authenticate(token) 方法實現

2. 將認證成功的認證資訊儲存到上下文,供後續訪問授權的時候獲取使用

3. 通過JWT生成令牌並返回給客戶端,後續訪問和操作都需要攜帶此令牌

有關登入過程的邏輯,參見SecurityUtils的login方法。

SecurityUtils.java

package com.louis.springboot.demo.utils;

import javax.servlet.http.HttpServletRequest;

import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;

import com.louis.springboot.demo.security.JwtAuthenticatioToken;

/**
 * Security相關操作
 * @author Louis
 * @date Jun 29, 2019
 */
public class SecurityUtils {

    /**
     * 系統登入認證
     * @param request
     * @param username
     * @param password
     * @param authenticationManager
     * @return
     */
    public static JwtAuthenticatioToken login(HttpServletRequest request, String username, String password, AuthenticationManager authenticationManager) {
        JwtAuthenticatioToken token = new JwtAuthenticatioToken(username, password);
        token.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        // 執行登入認證過程
        Authentication authentication = authenticationManager.authenticate(token);
        // 認證成功儲存認證資訊到上下文
        SecurityContextHolder.getContext().setAuthentication(authentication);
        // 生成令牌並返回給客戶端
        token.setToken(JwtTokenUtils.generateToken(authentication));
        return token;
    }

    /**
     * 獲取令牌進行認證
     * @param request
     */
    public static void checkAuthentication(HttpServletRequest request) {
        // 獲取令牌並根據令牌獲取登入認證資訊
        Authentication authentication = JwtTokenUtils.getAuthenticationeFromToken(request);
        // 設定登入認證資訊到上下文
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }

    /**
     * 獲取當前使用者名稱
     * @return
     */
    public static String getUsername() {
        String username = null;
        Authentication authentication = getAuthentication();
        if(authentication != null) {
            Object principal = authentication.getPrincipal();
            if(principal != null && principal instanceof UserDetails) {
                username = ((UserDetails) principal).getUsername();
            }
        }
        return username;
    }
    
    /**
     * 獲取使用者名稱
     * @return
     */
    public static String getUsername(Authentication authentication) {
        String username = null;
        if(authentication != null) {
            Object principal = authentication.getPrincipal();
            if(principal != null && principal instanceof UserDetails) {
                username = ((UserDetails) principal).getUsername();
            }
        }
        return username;
    }
    
    /**
     * 獲取當前登入資訊
     * @return
     */
    public static Authentication getAuthentication() {
        if(SecurityContextHolder.getContext() == null) {
            return null;
        }
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        return authentication;
    }
    
}

令牌生成器

我們令牌是使用JWT生成的,令牌生成的邏輯,參見原始碼JwtTokenUtils的generateToken相關方法。

JwtTokenUtils.java

package com.louis.springboot.demo.utils;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.servlet.http.HttpServletRequest;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;

import com.louis.springboot.demo.security.GrantedAuthorityImpl;
import com.louis.springboot.demo.security.JwtAuthenticatioToken;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

/**
 * JWT工具類
 * @author Louis
 * @date Jun 29, 2019
 */
public class JwtTokenUtils implements Serializable {

    private static final long serialVersionUID = 1L;
    
    /**
     * 使用者名稱稱
     */
    private static final String USERNAME = Claims.SUBJECT;
    /**
     * 建立時間
     */
    private static final String CREATED = "created";
    /**
     * 許可權列表
     */
    private static final String AUTHORITIES = "authorities";
    /**
     * 金鑰
     */
    private static final String SECRET = "abcdefgh";
    /**
     * 有效期12小時
     */
    private static final long EXPIRE_TIME = 12 * 60 * 60 * 1000;

    /**
     * 生成令牌
     *
     * @param userDetails 使用者
     * @return 令牌
     */
    public static String generateToken(Authentication authentication) {
        Map<String, Object> claims = new HashMap<>(3);
        claims.put(USERNAME, SecurityUtils.getUsername(authentication));
        claims.put(CREATED, new Date());
        claims.put(AUTHORITIES, authentication.getAuthorities());
        return generateToken(claims);
    }

    /**
     * 從資料宣告生成令牌
     *
     * @param claims 資料宣告
     * @return 令牌
     */
    private static String generateToken(Map<String, Object> claims) {
        Date expirationDate = new Date(System.currentTimeMillis() + EXPIRE_TIME);
        return Jwts.builder().setClaims(claims).setExpiration(expirationDate).signWith(SignatureAlgorithm.HS512, SECRET).compact();
    }

    /**
     * 從令牌中獲取使用者名稱
     *
     * @param token 令牌
     * @return 使用者名稱
     */
    public static String getUsernameFromToken(String token) {
        String username;
        try {
            Claims claims = getClaimsFromToken(token);
            username = claims.getSubject();
        } catch (Exception e) {
            username = null;
        }
        return username;
    }
    
    /**
     * 根據請求令牌獲取登入認證資訊
     * @param token 令牌
     * @return 使用者名稱
     */
    public static Authentication getAuthenticationeFromToken(HttpServletRequest request) {
        Authentication authentication = null;
        // 獲取請求攜帶的令牌
        String token = JwtTokenUtils.getToken(request);
        if(token != null) {
            // 請求令牌不能為空
            if(SecurityUtils.getAuthentication() == null) {
                // 上下文中Authentication為空
                Claims claims = getClaimsFromToken(token);
                if(claims == null) {
                    return null;
                }
                String username = claims.getSubject();
                if(username == null) {
                    return null;
                }
                if(isTokenExpired(token)) {
                    return null;
                }
                Object authors = claims.get(AUTHORITIES);
                List<GrantedAuthority> authorities = new ArrayList<GrantedAuthority>();
                if (authors != null && authors instanceof List) {
                    for (Object object : (List) authors) {
                        authorities.add(new GrantedAuthorityImpl((String) ((Map) object).get("authority")));
                    }
                }
                authentication = new JwtAuthenticatioToken(username, null, authorities, token);
            } else {
                if(validateToken(token, SecurityUtils.getUsername())) {
                    // 如果上下文中Authentication非空,且請求令牌合法,直接返回當前登入認證資訊
                    authentication = SecurityUtils.getAuthentication();
                }
            }
        }
        return authentication;
    }

    /**
     * 從令牌中獲取資料宣告
     *
     * @param token 令牌
     * @return 資料宣告
     */
    private static Claims getClaimsFromToken(String token) {
        Claims claims;
        try {
            claims = Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token).getBody();
        } catch (Exception e) {
            claims = null;
        }
        return claims;
    }

    /**
     * 驗證令牌
     * @param token
     * @param username
     * @return
     */
    public static Boolean validateToken(String token, String username) {
        String userName = getUsernameFromToken(token);
        return (userName.equals(username) && !isTokenExpired(token));
    }

    /**
     * 重新整理令牌
     * @param token
     * @return
     */
    public static String refreshToken(String token) {
        String refreshedToken;
        try {
            Claims claims = getClaimsFromToken(token);
            claims.put(CREATED, new Date());
            refreshedToken = generateToken(claims);
        } catch (Exception e) {
            refreshedToken = null;
        }
        return refreshedToken;
    }

    /**
     * 判斷令牌是否過期
     *
     * @param token 令牌
     * @return 是否過期
     */
    public static Boolean isTokenExpired(String token) {
        try {
            Claims claims = getClaimsFromToken(token);
            Date expiration = claims.getExpiration();
            return expiration.before(new Date());
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * 獲取請求token
     * @param request
     * @return
     */
    public static String getToken(HttpServletRequest request) {
        String token = request.getHeader("Authorization");
        String tokenHead = "Bearer ";
        if(token == null) {
            token = request.getHeader("token");
        } else if(token.contains(tokenHead)){
            token = token.substring(tokenHead.length());
        } 
        if("".equals(token)) {
            token = null;
        }
        return token;
    }

}

登入身份認證元件

上面說到登入認證是通過呼叫 AuthenticationManager 的 authenticate(token) 方法實現的,而 AuthenticationManager 又是通過呼叫 AuthenticationProvider 的 authenticate(Authentication authentication) 來完成認證的,所以通過定製 AuthenticationProvider 也可以完成各種自定義的需求,我們這裡只是簡單的繼承 DaoAuthenticationProvider 展示如何自定義,具體的大家可以根據各自的需求按需定製。

JwtAuthenticationProvider.java

package com.louis.springboot.demo.security;

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

/**
 * 身份驗證提供者
 * @author Louis
 * @date Jun 29, 2019
 */
public class JwtAuthenticationProvider extends DaoAuthenticationProvider {

    public JwtAuthenticationProvider(UserDetailsService userDetailsService) {
        setUserDetailsService(userDetailsService);
        setPasswordEncoder(new BCryptPasswordEncoder());
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        // 可以在此處覆寫整個登入認證邏輯
        return super.authenticate(authentication);
    }
    
    @Override
    protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication)
            throws AuthenticationException {
        // 可以在此處覆寫密碼驗證邏輯
        super.additionalAuthenticationChecks(userDetails, authentication);
    }

}

認證資訊獲取服務

通過跟蹤程式碼執行,我們發現像預設使用的 DaoAuthenticationProvider,在認證的使用都是通過一個叫 UserDetailsService 的來獲取使用者認證所需資訊的。

AbstractUserDetailsAuthenticationProvider 定義了在 authenticate 方法中通過 retrieveUser 方法獲取使用者資訊,子類 DaoAuthenticationProvider 通過 UserDetailsService 來進行獲取,一般情況,這個UserDetailsService需要我們自定義,實現從使用者服務獲取使用者和許可權資訊封裝到 UserDetails 的實現類。

AbstractUserDetailsAuthenticationProvider.java

public Authentication authenticate(Authentication authentication) throws AuthenticationException {      
     ...
  if (user == null) { cacheWasUsed = false; try { user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); } ...
    return createSuccessAuthentication(principalToReturn, authentication, user); }

DaoAuthenticationProvider.java

 protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
            throws AuthenticationException {
        try {

            UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
       return loadedUser;
        }
        ...
    }

我們自定義的 UserDetailsService,從我們的使用者服務 UserService 中獲取使用者和許可權資訊。

UserDetailsServiceImpl.java

package com.louis.springboot.demo.security;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import com.louis.springboot.demo.model.User;
import com.louis.springboot.demo.service.UserService;

/**
 * 使用者登入認證資訊查詢
 * @author Louis
 * @date Jun 29, 2019
 */
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserService userService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userService.findByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("該使用者不存在");
        }
        // 使用者許可權列表,根據使用者擁有的許可權標識與如 @PreAuthorize("hasAuthority('sys:menu:view')") 標註的介面對比,決定是否可以呼叫介面
        Set<String> permissions = userService.findPermissions(username);
        List<GrantedAuthority> grantedAuthorities = permissions.stream().map(GrantedAuthorityImpl::new).collect(Collectors.toList());
        return new JwtUserDetails(username, user.getPassword(), grantedAuthorities);
    }
}

一般而言,定製 UserDetailsService 就可以滿足大部分需求了,在 UserDetailsService 滿足不了我們的需求的時候考慮定製 AuthenticationProvider。

如果直接定製UserDetailsService ,而不自定義 AuthenticationProvider,可以直接在配置檔案 WebSecurityConfig 中這樣配置。

@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
    // 指定自定義的獲取資訊獲取服務
    auth.userDetailsService(userDetailsService)
}

使用者認證資訊

上面 UserDetailsService 載入好使用者認證資訊後會封裝認證資訊到一個 UserDetails 的實現類。

預設實現是 User 類,我們這裡沒有特殊需要,簡單繼承即可,複雜需求可以在此基礎上進行拓展。

JwtUserDetails.java

package com.louis.springboot.demo.security;
import java.util.Collection;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;

/**
 * 安全使用者模型
 * @author Louis
 * @date Jun 29, 2019
 */
public class JwtUserDetails extends User {

    private static final long serialVersionUID = 1L;

    public JwtUserDetails(String username, String password, Collection<? extends GrantedAuthority> authorities) {
        this(username, password, true, true, true, true, authorities);
    }
    
    public JwtUserDetails(String username, String password, boolean enabled, boolean accountNonExpired,
            boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) {
        super(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities);
    }

}

使用者操作程式碼

簡單的使用者模型,包含使用者名稱密碼。

User.java

package com.louis.springboot.demo.model;

/**
 * 使用者模型
 * @author Louis
 * @date Jun 29, 2019
 */
public class User {

    private Long id;
    
    private String username;

    private String password;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

}

使用者服務介面,只提供簡單的使用者查詢和許可權查詢介面用於模擬。

UserService.java

package com.louis.springboot.demo.service;

import java.util.Set;

import com.louis.springboot.demo.model.User;

/**
 * 使用者管理
 * @author Louis
 * @date Jun 29, 2019
 */
public interface UserService {

    /**
     * 根據使用者名稱查詢使用者
     * @param username
     * @return
     */
    User findByUsername(String username);

    /**
     * 查詢使用者的選單許可權標識集合
     * @param userName
     * @return
     */
    Set<String> findPermissions(String username);

}

使用者服務實現,只簡單獲取返回模擬資料,實際場景根據情況從DAO獲取即可。

SysUserServiceImpl.java

package com.louis.springboot.demo.service.impl;

import java.util.HashSet;
import java.util.Set;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import com.louis.springboot.demo.model.User;
import com.louis.springboot.demo.service.UserService;

@Service
public class SysUserServiceImpl implements UserService {

    @Override
    public User findByUsername(String username) {
        User user = new User();
        user.setId(1L);
        user.setUsername(username);
        String password = new BCryptPasswordEncoder().encode("123");
        user.setPassword(password);
        return user;
    }

    @Override
    public Set<String> findPermissions(String username) {
        Set<String> permissions = new HashSet<>();
        permissions.add("sys:user:view");
        permissions.add("sys:user:add");
        permissions.add("sys:user:edit");
        permissions.add("sys:user:delete");
        return permissions;
    }

}

使用者控制器,提供三個測試介面,其中許可權列表中未包含刪除介面定義的許可權('sys:user:delete'),登入之後也將無許可權呼叫。

UserController.java

package com.louis.springboot.demo.controller;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.louis.springboot.demo.vo.HttpResult;

/**
 * 使用者控制器
 * @author Louis
 * @date Jun 29, 2019
 */
@RestController
@RequestMapping("user")
public class UserController {

    
    @PreAuthorize("hasAuthority('sys:user:view')")
    @GetMapping(value="/findAll")
    public HttpResult findAll() {
        return HttpResult.ok("the findAll service is called success.");
    }
    
    @PreAuthorize("hasAuthority('sys:user:edit')")
    @GetMapping(value="/edit")
    public HttpResult edit() {
        return HttpResult.ok("the edit service is called success.");
    }
    
    @PreAuthorize("hasAuthority('sys:user:delete')")
    @GetMapping(value="/delete")
    public HttpResult delete() {
        return HttpResult.ok("the delete service is called success.");
    }

}

登入認證檢查過濾器

訪問介面的時候,登入認證檢查過濾器 JwtAuthenticationFilter 會攔截請求校驗令牌和登入狀態,並根據情況設定登入狀態。

JwtAuthenticationFilter.java

package com.louis.springboot.demo.security;

import java.io.IOException;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;

import com.louis.springboot.demo.utils.SecurityUtils;

/**
 * 登入認證檢查過濾器
 * @author Louis
 * @date Jun 29, 2019
 */
public class JwtAuthenticationFilter extends BasicAuthenticationFilter {
    
    @Autowired
    public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        // 獲取token, 並檢查登入狀態
        SecurityUtils.checkAuthentication(request);
        chain.doFilter(request, response);
    }
    
}

具體詳細獲取token和檢查登入狀態程式碼請檢視SecurityUtils的checkAuthentication方法。

編譯測試執行

1.  右鍵專案 -> Run as -> Maven install,開始執行Maven構建,第一次會下載Maven依賴,可能需要點時間,如果出現如下資訊,就說明專案編譯打包成功了。

2.  右鍵檔案 DemoApplication.java -> Run as -> Java Application,開始啟動應用,當出現如下資訊的時候,就說明應用啟動成功了,預設啟動埠是8080。

3.  開啟瀏覽器,訪問:http://localhost:8080/swagger-ui.html,進入swagger介面文件介面。

 4.我們先再未登入沒有令牌的時候直接訪問介面,發現都返回無許可權,禁止訪問的結果。

發現介面呼叫失敗,返回狀態碼為403的錯誤,表示因為許可權的問題拒絕訪問。

 開啟 LoginController,輸入我們使用者名稱和密碼(username:amdin, password:123,密碼是我們在SysUserServiceImpl中設定的)

 

登入成功之後,會成功返回令牌,如下圖所示。

拷貝返回的令牌,貼上到令牌引數輸入框,再次訪問 /user/edit 介面。

這個時候,成功的返回了結果: the edit service is called success.

同樣的,拷貝返回的令牌,貼上到令牌引數輸入框,訪問 /user/delete 介面。

發現還是返回拒絕訪問的結果,那是因為訪問這個介面需要 'sys:user:delete' 許可權,而我們之前返回的許可權列表中並沒有包含,所以授權訪問失敗。

我們可以修改一下 SysUserServiceImpl,新增上‘sys:user:delete’ 許可權,重新登入,再次訪問一遍。

發現刪除介面也可以訪問了,記住務必要重新呼叫登入介面,獲取令牌後拷貝到刪除介面,再次訪問刪除介面。

到此,一個簡單但相對完整的Spring Security案例就實現了,我們通過Spring Security實現了簡單的登入認證和訪問控制,讀者可以在此基礎上拓展出更為豐富的功能。

流程剖析

Spring Security的安全主要包含兩部分內容,即登入認證和訪問授權,接下來,我們別對這兩個部分的流程進行追蹤和分析,分析過程中,讀者最好同時對比檢視相應原始碼,以更好的學習和了解相關的內容。

登入認證

登入認證過濾器

如果在繼承 WebSecurityConfigurerAdapter 的配置類中的 configure(HttpSecurity http) 方法中有配置 HttpSecurity 的 formLogin,則會返回一個 FormLoginConfigurer 物件。如下是一個 Spring Security 的配置樣例, formLogin().x.x 就是配置使用內建的登入驗證過濾器,預設實現為 UsernamePasswordAuthenticationFilter。

WebSecurityConfig.java

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;
    
    @Override
    public void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 使用自定義身份驗證元件
        auth.authenticationProvider(new JwtAuthenticationProvider(userDetailsService));
    }
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.cors().and().csrf().disable()
            .authorizeRequests()
        // 首頁和登入頁面
        .antMatchers("/").permitAll()
        // 其他所有請求需要身份認證
        .anyRequest().authenticated()
        // 配置登入認證
        .and().formLogin().loginProcessingUrl("/login");
    }
}

檢視 HttpSecurity的formLogion 方法,發現返回的是一個 FormLoginConfigurer 物件。

HttpSecurity.java

public FormLoginConfigurer<HttpSecurity> formLogin() throws Exception {
    return getOrApply(new FormLoginConfigurer<>());
}

而 FormLoginConfigurer 的建構函式內綁定了一個 UsernamePasswordAuthenticationFilter 過濾器。

FormLoginConfigurer.java

public FormLoginConfigurer() {
    super(new UsernamePasswordAuthenticationFilter(), null);
    usernameParameter("username");
    passwordParameter("password");
}

接著檢視 UsernamePasswordAuthenticationFilter 過濾器,發現其建構函式內綁定了 POST 型別的 /login 請求,也就是說,如果配置了 formLogin 的相關資訊,那麼在使用 POST 型別的 /login URL進行登入的時候就會被這個過濾器攔截,並進行登入驗證,登入驗證過程我們下面繼續分析。

UsernamePasswordAuthenticationFilter.java

public UsernamePasswordAuthenticationFilter() {
    super(new AntPathRequestMatcher("/login", "POST"));
}

檢視 UsernamePasswordAuthenticationFilter,發現它繼承了 AbstractAuthenticationProcessingFilter,AbstractAuthenticationProcessingFilter 中的 doFilter 包含了觸發登入認證執行流程的相關邏輯。

AbstractAuthenticationProcessingFilter.java

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

        ...

        Authentication authResult;
        try {
            authResult = attemptAuthentication(request, response);
        
       ...
sessionStrategy.onAuthentication(authResult, request, response); }      ... successfulAuthentication(request, response, chain, authResult); }

上面的登入邏輯主要步驟有兩個:

1. attemptAuthentication(request, response)

這是 AbstractAuthenticationProcessingFilter  中的一個抽象方法,包含登入主邏輯,由其子類實現具體的登入驗證,如 UsernamePasswordAuthenticationFilter 是使用表單方式登入的具體實現。如果是非表單登入的方式,如JNDI等其他方式登入的可以通過繼承 AbstractAuthenticationProcessingFilter 自定義登入實現。UsernamePasswordAuthenticationFilter 的登入實現邏輯如下。

UsernamePasswordAuthenticationFilter.java

public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
     // 獲取使用者名稱和密碼
        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);
    }

2. successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult)

登入成功之後,將認證後的 Authentication 物件儲存到請求執行緒上下文,這樣在授權階段就可以獲取到 Authentication 認證資訊,並利用 Authentication 內的許可權資訊進行訪問控制判斷。

AbstractAuthenticationProcessingFilter.java

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

  // 登入成功之後,把認證後的 Authentication 物件儲存到請求執行緒上下文,這樣在授權階段就可以獲取到此認證資訊進行訪問控制判斷
    SecurityContextHolder.getContext().setAuthentication(authResult);

    rememberMeServices.loginSuccess(request, response, authResult);

    successHandler.onAuthenticationSuccess(request, response, authResult);
}

從上面的登入邏輯我們可以看到,Spring Security的登入認證過程是委託給 AuthenticationManager 完成的,它先是解析出使用者名稱和密碼,然後把使用者名稱和密碼封裝到一個UsernamePasswordAuthenticationToken 中,傳遞給 AuthenticationManager,交由 AuthenticationManager 完成實際的登入認證過程。 

AuthenticationManager.java

package org.springframework.security.authentication;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;

/**
* Processes an {@link Authentication} request.
* @author Ben Alex
*/
public interface AuthenticationManager {

  Authentication authenticate(Authentication authentication) throws AuthenticationException;
}

AuthenticationManager 提供了一個預設的 實現 ProviderManager,而 ProviderManager 又將驗證委託給了 AuthenticationProvider。

ProviderManager.java

public Authentication authenticate(Authentication authentication) throws AuthenticationException {
     ...
   for (AuthenticationProvider provider : getProviders()) {
        if (!provider.supports(toTest)) {
            continue;
        }try {
       // 委託給AuthenticationProvider result = provider.authenticate(authentication); }    } }

根據驗證方式的多樣化,AuthenticationProvider 衍生出多種型別的實現,AbstractUserDetailsAuthenticationProvider 是 AuthenticationProvider 的抽象實現,定義了較為統一的驗證邏輯,各種驗證方式可以選擇直接繼承 AbstractUserDetailsAuthenticationProvider 完成登入認證,如 DaoAuthenticationProvider 就是繼承了此抽象類,完成了從DAO方式獲取驗證需要的使用者資訊的。

AbstractUserDetailsAuthenticationProvider.java

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);

       // 進行一般邏輯認證,如 DaoAuthenticationProvider 實現中的密碼驗證就是在這裡完成的
            additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
        }
        ...

     // 後置檢查,如可以檢查密碼是否過期之類
        postAuthenticationChecks.check(user);

     ...
     // 驗證成功之後返回包含完整認證資訊的 Authentication 物件
        return createSuccessAuthentication(principalToReturn, authentication, user);
    }

如上面所述, AuthenticationProvider 通過 retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) 獲取驗證資訊,對於我們一般所用的 DaoAuthenticationProvider 是由 UserDetailsService 專門負責獲取驗證資訊的。

DaoAuthenticationProvider.java

protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
    try {
        UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
        if (loadedUser == null) {
            throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");
        }
        return loadedUser;
    }
}

UserDetailsService 介面只有一個方法,loadUserByUsername(String username),一般需要我們實現此介面方法,根據使用者名稱載入登入認證和訪問授權所需要的資訊,並返回一個 UserDetails的實現類,後面登入認證和訪問授權都需要用到此中的資訊。

public interface UserDetailsService {
    /**
     * Locates the user based on the username. In the actual implementation, the search
     * may possibly be case sensitive, or case insensitive depending on how the
     * implementation instance is configured. In this case, the <code>UserDetails</code>
     * object that comes back may have a username that is of a different case than what
     * was actually requested..
     *
     * @param username the username identifying the user whose data is required.
     *
     * @return a fully populated user record (never <code>null</code>)
     *
     * @throws UsernameNotFoundException if the user could not be found or the user has no
     * GrantedAuthority
     */
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

UserDetails 提供了一個預設實現 User,主要包含使用者名稱(username)、密碼(password)、許可權(authorities)和一些賬號或密碼狀態的標識。

如果預設實現滿足不了你的需求,可以根據需求定製自己的 UserDetails,然後在 UserDetailsService 的 loadUserByUsername 中返回即可。

public class User implements UserDetails, CredentialsContainer {// ~ Instance fields
    // ================================================================================================
    private String password;
    private final String username;
    private final Set<GrantedAuthority> authorities;
    private final boolean accountNonExpired;
    private final boolean accountNonLocked;
    private final boolean credentialsNonExpired;
    private final boolean enabled;

    // ~ Constructors
    // ===================================================================================================
    public User(String username, String password,
            Collection<? extends GrantedAuthority> authorities) {
        this(username, password, true, true, true, true, authorities);
    }

   ...
}

退出登入

Spring Security 提供了一個預設的登出過濾器 LogoutFilter,預設攔截路徑是 /logout,當訪問 /logout 路徑的時候,LogoutFilter 會進行退出處理。

LogoutFilter.java

public class LogoutFilter extends GenericFilterBean {

    public LogoutFilter(LogoutSuccessHandler logoutSuccessHandler,
            LogoutHandler... handlers) {
        this.handler = new CompositeLogoutHandler(handlers);this.logoutSuccessHandler = logoutSuccessHandler;
        setFilterProcessesUrl("/logout");  // 繫結 /logout
    }
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) res;

        if (requiresLogout(request, response)) {
            Authentication auth = SecurityContextHolder.getContext().getAuthentication();this.handler.logout(request, response, auth);  // 登出處理,可能包含session、cookie、認證資訊的清理工作

            logoutSuccessHandler.onLogoutSuccess(request, response, auth);  // 退出後的操作,可能是跳轉、返回成功狀態等

            return;
        }

        chain.doFilter(request, response);
    }

   ...
}

如下是 SecurityContextLogoutHandler 中的登出處理實現。

SecurityContextLogoutHandler.java

public void logout(HttpServletRequest request, HttpServletResponse response,
        Authentication authentication) {
    // 讓 session 失效 
  if (invalidateHttpSession) {
        HttpSession session = request.getSession(false);
        if (session != null) {
            logger.debug("Invalidating session: " + session.getId());
            session.invalidate();
        }
    }
     // 清理 Security 上下文,其中包含登入認證資訊
    if (clearAuthentication) {
        SecurityContext context = SecurityContextHolder.getContext();
        context.setAuthentication(null);
    }
    SecurityContextHolder.clearContext();
}

訪問授權

訪問授權主要分為兩種:通過URL方式的介面訪問控制和方法呼叫的許可權控制。

介面訪問許可權

在通過比如瀏覽器使用URL訪問後臺介面時,是否允許訪問此URL,就是介面訪問許可權。

在進行介面訪問時,會由 FilterSecurityInterceptor 進行攔截並進行授權。

FilterSecurityInterceptor 繼承了 AbstractSecurityInterceptor 並實現了 javax.servlet.Filter 介面, 所以在URL訪問的時候都會被過濾器攔截,doFilter 實現如下。

FilterSecurityInterceptor.java

public void doFilter(ServletRequest request, ServletResponse response,
        FilterChain chain) throws IOException, ServletException {
    FilterInvocation fi = new FilterInvocation(request, response, chain);
    invoke(fi);
}

doFilter 方法又呼叫了自身的 invoke 方法, invoke 方法又呼叫了父類 AbstractSecurityInterceptor 的 beforeInvocation 方法。

FilterSecurityInterceptor.java

public void invoke(FilterInvocation fi) throws IOException, ServletException {
    if ((fi.getRequest() != null)
            && (fi.getRequest().getAttribute(FILTER_APPLIED) != null)
            && observeOncePerRequest) {
        // filter already applied to this request and user wants us to observe
        // once-per-request handling, so don't re-do security checking
        fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
    }
    else {
        // first time this request being called, so perform security checking
        if (fi.getRequest() != null && observeOncePerRequest) {
            fi.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
        }

        InterceptorStatusToken token = super.befor