Spring Boot:整合Spring Security

綜合概述

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

Spring Security 應用級別的安全主要包含兩個主要部分,即登陸認證(Authentication)和訪問受權(Authorization),首先用戶登陸的時候傳入登陸信息,登陸驗證器完成登陸認證並將登陸認證好的信息存儲到請求上下文,而後再進行其餘操做,如在進行接口訪問、方法調用時,權限認證器從上下文中獲取登陸認證信息,而後根據認證信息獲取權限信息,經過權限信息和特定的受權策略決定是否受權。java

本教程將首先給出一個完整的案例實現,而後再分別對登陸認證和訪問受權的執行流程進行剖析,但願你們能夠經過實現案例和流程分析,充分理解Spring Security的登陸認證和訪問受權的執行原理,而且可以在理解原理的基礎上熟練自主的使用Spring Security實現相關的需求。web

實現案例

接下來,咱們就經過一個具體的案例,來說解如何進行Spring Security的整合,而後藉助Spring Security實現登陸認證和訪問控制。spring

生成項目模板

爲方便咱們初始化項目,Spring Boot給咱們提供一個項目模板生成網站。sql

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

2.  根據頁面提示,選擇構建工具,開發語言,項目信息等。apache

3.  點擊 Generate the project,生成項目模板,生成以後會將壓縮包下載到本地。json

4.  使用IDE導入項目,我這裏使用Eclipse,經過導入Maven項目的方式導入。後端

添加相關依賴

清理掉不須要的測試類及測試依賴,添加 Maven 相關依賴,這裏須要添加上web、swagger、spring security、jwt和fastjson的依賴,Swagge和fastjson的添加是爲了方便接口測試。api

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.beforeInvocation(fi);

        try {
            fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
        }
        finally {
            super.finallyInvocation(token);
        }

        super.afterInvocation(token, null);
    }
}
複製代碼

方法調用權限

在進行後臺方法調用時,是否容許該方法調用,就是方法調用權限。好比在方法上添加了此類註解 @PreAuthorize("hasRole('ROLE_ADMIN')") ,Security 方法註解的支持須要在任何配置類中(如 WebSecurityConfigurerAdapter )添加 @EnableGlobalMethodSecurity(prePostEnabled = true) 開啓,纔可以使用。

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

}

在進行方法調用時,會由 MethodSecurityInterceptor 進行攔截並進行受權。

MethodSecurityInterceptor 繼承了 AbstractSecurityInterceptor 並實現了AOP 的 org.aopalliance.intercept.MethodInterceptor 接口, 因此能夠在方法調用時進行攔截。

MethodSecurityInterceptor .java

複製代碼
public Object invoke(MethodInvocation mi) throws Throwable {
    InterceptorStatusToken token = super.beforeInvocation(mi);

    Object result;
    try {
        result = mi.proceed();
    }
    finally {
        super.finallyInvocation(token);
    }
    return super.afterInvocation(token, result);
}
複製代碼

咱們看到,MethodSecurityInterceptor 跟 FilterSecurityInterceptor 同樣, 都是經過調用父類 AbstractSecurityInterceptor 的相關方法完成受權,其中 beforeInvocation 是完成權限認證的關鍵。

AbstractSecurityInterceptor.java

複製代碼
protected InterceptorStatusToken beforeInvocation(Object object) {
        ...
     // 經過 SecurityMetadataSource 獲取權限配置信息,能夠定製實現本身的權限信息獲取邏輯
        Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object);

     ...

     // 確認是否通過登陸認證     
        Authentication authenticated = authenticateIfRequired();

        try {
        // 經過 AccessDecisionManager 完成受權認證,默認實現是 AffirmativeBased
            this.accessDecisionManager.decide(authenticated, object, attributes);
        }
        ...
    }
複製代碼

上面代碼顯示 AbstractSecurityInterceptor 又是委託受權認證器 AccessDecisionManager 完成受權認證,默認實現是 AffirmativeBased, decide 方法實現以下。

AffirmativeBased.java

複製代碼
public void decide(Authentication authentication, Object object,
        Collection<ConfigAttribute> configAttributes) throws AccessDeniedException {
    int deny = 0;

    for (AccessDecisionVoter voter : getDecisionVoters()) {
  
     // 經過各類投票策略,最終決定是否受權 
        int result = voter.vote(authentication, object, configAttributes);
        
        ...
    }    
}
複製代碼

而 AccessDecisionManager 決定受權又是經過一個受權策略集合(AccessDecisionVoter )決定的,受權決定的原則是:

  1. 遍歷全部受權策略, 若是有其中一個返回 ACCESS_GRANTED,則贊成受權。

  2. 不然,等待遍歷結束,統計 ACCESS_DENIED 個數,只要拒絕數大於1,則不一樣意受權。

對於接口訪問受權,也就是 FilterSecurityInterceptor 管理的URL受權,默認對應的受權策略只有一個,就是 WebExpressionVoter,它的受權策略主要是根據 WebSecurityConfigurerAdapter 內配置的路徑訪問策略進行匹配,而後決定是否受權。

WebExpressionVoter.java

複製代碼
/**
 * Voter which handles web authorisation decisions.
 * @author Luke Taylor
 * @since 3.0
 */
public class WebExpressionVoter implements AccessDecisionVoter<FilterInvocation> {
    private SecurityExpressionHandler<FilterInvocation> expressionHandler = new DefaultWebSecurityExpressionHandler();

    public int vote(Authentication authentication, FilterInvocation fi,
            Collection<ConfigAttribute> attributes) {
        assert authentication != null;
        assert fi != null;
        assert attributes != null;

        WebExpressionConfigAttribute weca = findConfigAttribute(attributes);

        if (weca == null) {
            return ACCESS_ABSTAIN;
        }

        EvaluationContext ctx = expressionHandler.createEvaluationContext(authentication, fi);

        ctx = weca.postProcess(ctx, fi);

        return ExpressionUtils.evaluateAsBoolean(weca.getAuthorizeExpression(), ctx) ? ACCESS_GRANTED : ACCESS_DENIED;
    }

    ...
}
複製代碼

對於方法調用受權,在全局方法安全配置類裏,能夠看到給 MethodSecurityInterceptor 默認配置的有 RoleVoter、AuthenticatedVoter、Jsr250Voter、和 PreInvocationAuthorizationAdviceVoter,其中 Jsr250Voter、PreInvocationAuthorizationAdviceVoter 都須要打開指定的開關,纔會添加支持。

GlobalMethodSecurityConfiguration.java

複製代碼
@Configuration
public class GlobalMethodSecurityConfiguration implements ImportAware, SmartInitializingSingleton {

    ...
    private MethodSecurityInterceptor methodSecurityInterceptor;
        
  @Bean
    public MethodInterceptor methodSecurityInterceptor() throws Exception {
        this.methodSecurityInterceptor = isAspectJ()
                ? new AspectJMethodSecurityInterceptor()
                : new MethodSecurityInterceptor();
        methodSecurityInterceptor.setAccessDecisionManager(accessDecisionManager());
        methodSecurityInterceptor.setAfterInvocationManager(afterInvocationManager());
        methodSecurityInterceptor
                .setSecurityMetadataSource(methodSecurityMetadataSource());
        RunAsManager runAsManager = runAsManager();
        if (runAsManager != null) {
            methodSecurityInterceptor.setRunAsManager(runAsManager);
        }

        return this.methodSecurityInterceptor;
    }
    
    protected AccessDecisionManager accessDecisionManager() {
        List<AccessDecisionVoter<? extends Object>> decisionVoters = new ArrayList<AccessDecisionVoter<? extends Object>>();
        ExpressionBasedPreInvocationAdvice expressionAdvice = new ExpressionBasedPreInvocationAdvice();
        expressionAdvice.setExpressionHandler(getExpressionHandler());
        if (prePostEnabled()) {
            decisionVoters
                    .add(new PreInvocationAuthorizationAdviceVoter(expressionAdvice));
        }
        if (jsr250Enabled()) {
            decisionVoters.add(new Jsr250Voter());
        }
        decisionVoters.add(new RoleVoter());
        decisionVoters.add(new AuthenticatedVoter());
        return new AffirmativeBased(decisionVoters);
    }

  ...
}
複製代碼

RoleVoter 是根據角色進行匹配受權的策略。

RoleVoter.java

複製代碼
public class RoleVoter implements AccessDecisionVoter<Object> {
   // RoleVoter  默認角色名以 "ROLE_" 爲前綴。
    private String rolePrefix = "ROLE_";public boolean supports(ConfigAttribute attribute) {
        if ((attribute.getAttribute() != null)
                && attribute.getAttribute().startsWith(getRolePrefix())) {
            return true;
        } else {
            return false;
        }
    }
    
    public int vote(Authentication authentication, Object object,
            Collection<ConfigAttribute> attributes) {
        if(authentication == null) {
            return ACCESS_DENIED;
        }
        int result = ACCESS_ABSTAIN;
        Collection<? extends GrantedAuthority> authorities = extractAuthorities(authentication);
     // 逐個角色進行匹配,入股有一個匹配得上,則進行受權
        for (ConfigAttribute attribute : attributes) {
            if (this.supports(attribute)) {
                result = ACCESS_DENIED;
                // Attempt to find a matching granted authority
                for (GrantedAuthority authority : authorities) {
                    if (attribute.getAttribute().equals(authority.getAuthority())) {
                        return ACCESS_GRANTED;
                    }
                }
            }
        }

        return result;
    }
}
複製代碼

AuthenticatedVoter 主要是針對有配置如下幾個屬性來決定受權的策略。

IS_AUTHENTICATED_REMEMBERED:記住我登陸狀態

IS_AUTHENTICATED_ANONYMOUSLY:匿名認證狀態

IS_AUTHENTICATED_FULLY: 徹底登陸狀態,即非上面兩種類型

AuthenticatedVoter.java

複製代碼
public int vote(Authentication authentication, Object object,
        Collection<ConfigAttribute> attributes) {
    int result = ACCESS_ABSTAIN;

    for (ConfigAttribute attribute : attributes) {
        if (this.supports(attribute)) {
            result = ACCESS_DENIED;
       // 徹底登陸狀態
            if (IS_AUTHENTICATED_FULLY.equals(attribute.getAttribute())) {
                if (isFullyAuthenticated(authentication)) {
                    return ACCESS_GRANTED;
                }
            }
       // 記住我登陸狀態
            if (IS_AUTHENTICATED_REMEMBERED.equals(attribute.getAttribute())) {
                if (authenticationTrustResolver.isRememberMe(authentication)
                        || isFullyAuthenticated(authentication)) {
                    return ACCESS_GRANTED;
                }
            }
       // 匿名登陸狀態
            if (IS_AUTHENTICATED_ANONYMOUSLY.equals(attribute.getAttribute())) {
                if (authenticationTrustResolver.isAnonymous(authentication)
                        || isFullyAuthenticated(authentication)
                        || authenticationTrustResolver.isRememberMe(authentication)) {
                    return ACCESS_GRANTED;
                }
            }
        }
    }

    return result;
}
複製代碼

PreInvocationAuthorizationAdviceVoter 是針對相似  @PreAuthorize("hasRole('ROLE_ADMIN')")  註解解析並進行受權的策略。

PreInvocationAuthorizationAdviceVoter.java

複製代碼
public class PreInvocationAuthorizationAdviceVoter implements AccessDecisionVoter<MethodInvocation> {private final PreInvocationAuthorizationAdvice preAdvice;
public int vote(Authentication authentication, MethodInvocation method,
            Collection<ConfigAttribute> attributes) {

        PreInvocationAttribute preAttr = findPreInvocationAttribute(attributes);

        if (preAttr == null) {
            // No expression based metadata, so abstain
            return ACCESS_ABSTAIN;
        }

        boolean allowed = preAdvice.before(authentication, method, preAttr);

        return allowed ? ACCESS_GRANTED : ACCESS_DENIED;
    }

    private PreInvocationAttribute findPreInvocationAttribute(
            Collection<ConfigAttribute> config) {
        for (ConfigAttribute attribute : config) {
            if (attribute instanceof PreInvocationAttribute) {
                return (PreInvocationAttribute) attribute;
            }
        }
        return null;
    }
}
複製代碼

PreInvocationAuthorizationAdviceVoter 解析出註解屬性配置, 而後經過調用 PreInvocationAuthorizationAdvice 的前置通知方法進行受權認證,默認實現相似 ExpressionBasedPreInvocationAdvice,通知內主要進行了內容的過濾和權限表達式的匹配。

ExpressionBasedPreInvocationAdvice.java

複製代碼
public class ExpressionBasedPreInvocationAdvice implements PreInvocationAuthorizationAdvice {
    private MethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();

    public boolean before(Authentication authentication, MethodInvocation mi, PreInvocationAttribute attr) {
        PreInvocationExpressionAttribute preAttr = (PreInvocationExpressionAttribute) attr;
        EvaluationContext ctx = expressionHandler.createEvaluationContext(authentication, mi);
        Expression preFilter = preAttr.getFilterExpression();
        Expression preAuthorize = preAttr.getAuthorizeExpression();

        if (preFilter != null) {
            Object filterTarget = findFilterTarget(preAttr.getFilterTarget(), ctx, mi);
            expressionHandler.filter(filterTarget, preFilter, ctx);
        }

        if (preAuthorize == null) {
            return true;
        }

        return ExpressionUtils.evaluateAsBoolean(preAuthorize, ctx);
    }

  ...
}
複製代碼

到這裏,咱們對Spring Securiy的登陸認證和訪問受權兩部分的執行流程大體進行了追蹤和分析,但願讀者能夠親自跟隨源碼調試這個過程,通過反覆對比論證,進一步的加深對Spring Securiy總體流程的理解,從而提升自身在實際項目運用中的分析能力和解決能力。

參考資料

官方網站:https://spring.io/projects/spring-security

W3C資料:https://www.w3cschool.cn/springsecurity/

參考手冊:https://springcloud.cc/spring-security-zhcn.html

相關導航

Spring Boot 系列教程目錄導航

Spring Boot:快速入門教程

Spring Boot:整合Swagger文檔

Spring Boot:整合MyBatis框架

Spring Boot:實現MyBatis分頁

相關文章
相關標籤/搜索