Spring Security技術棧學習筆記(八)Spring Security的基本運行原理與個性化登錄實現

正如你可能知道的兩個應用程序的兩個主要區域是“認證”和“授權”(或者訪問控制)。這兩個主要區域是Spring Security的兩個目標。“認證”,是建立一個他聲明的主題的過程(一個“主體”一般是指用戶,設備或一些可以在你的應用程序中執行動作的其他系統)。“授權”指確定一個主體是否允許在你的應用程序執行一個動作的過程。爲了抵達需要授權的店,主體的身份已經有認證過程建立。

一、Spring Security的基本原理

Spring Security的整個工作流程如下所示:
這裏寫圖片描述
其中綠色部分的每一種過濾器代表着一種認證方式,主要工作檢查當前請求有沒有關於用戶信息,如果當前的沒有,就會跳入到下一個綠色的過濾器中,請求成功會打標記。綠色認證方式可以配置,比如短信認證,微信。比如如果我們不配置BasicAuthenticationFilter的話,那麼它就不會生效。

FilterSecurityInterceptor過濾器是最後一個,它會決定當前的請求可不可以訪問Controller,判斷規則放在這個裏面。當不通過時會把異常拋給在這個過濾器的前面的ExceptionTranslationFilter過濾器。

ExceptionTranslationFilter接收到異常信息時,將跳轉頁面引導用戶進行認證。橘黃色和藍色的位置不可更改。當沒有認證的request進入過濾器鏈時,首先進入到FilterSecurityInterceptor,判斷當前是否進行了認證,如果沒有認證則進入到ExceptionTranslationFilter,進行拋出異常,然後跳轉到認證頁面(登錄界面)。

二、自定義認證邏輯

Spring Security將用戶信息的獲取邏輯封裝在一個接口裏面,這個接口是UserDetailsService,這個接口只有一個方法:

UserDetails loadUserByUsername(String username) throws UsernameNotFoundException

這個方法需要傳遞一個參數,這個參數是username,通過username就可以去數據庫查詢用戶信息,如果查詢到,就可以將查詢到的相關信息封裝到UserDetail的一個實現類對象中,並返回,然後就可以交給Spring Security進行認證,如果沒有查到,將拋出UsernameNotFoundException異常。返回的用戶對象是User,它是org.springframework.security.core.userdetails.User提供的實體類,這個實體類有幾個成員屬性,分別是:

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;  // 第七個是賬戶是否有效。

這個實體類有兩個構造方法,分別是:

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

public User(String username, String password, boolean enabled,
			boolean accountNonExpired, boolean credentialsNonExpired,
			boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) {

		if (((username == null) || "".equals(username)) || (password == null)) {
			throw new IllegalArgumentException(
					"Cannot pass null or empty values to constructor");
		}

		this.username = username;
		this.password = password;
		this.enabled = enabled;
		this.accountNonExpired = accountNonExpired;
		this.credentialsNonExpired = credentialsNonExpired;
		this.accountNonLocked = accountNonLocked;
		this.authorities = Collections.unmodifiableSet(sortAuthorities(authorities));
	}

對於自定義認證邏輯,這裏提供可運行的代碼:

package com.lemon.security.browser;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

/**
 * @author lemon
 * @date 2018/4/4 下午4:00
 */
@Component
@Slf4j
public class UserDetailsServiceImpl implements UserDetailsService {

    private final PasswordEncoder passwordEncoder;

    @Autowired
    public UserDetailsServiceImpl(PasswordEncoder passwordEncoder) {
        this.passwordEncoder = passwordEncoder;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("登陸用戶名: {}", username);
        // 這裏可以根據用戶名到數據庫中查詢用戶,獲得數據庫中得到的密碼(這裏不進行查詢操作,使用固定代碼)
        // 在實際的開發中,存到數據庫的密碼不是明文的,而是經過加密的
        String password = "123456";
        String encodedPassword = passwordEncoder.encode(password);
        log.info("加密後的密碼爲: {}", encodedPassword);
        // 這裏查詢該賬戶是否過期,這裏使用固定代碼,假設沒有過期
        boolean accountNonExpired = true;
        // 這裏查詢該賬戶被刪除,假設沒有被刪除
        boolean enabled = true;
        // 這裏查詢該賬戶認證是否過期,假設沒有過期
        boolean credentialsNonExpired = true;
        // 查詢該賬戶是否被鎖定,假設沒有被鎖定
        boolean accountNonLocked = true;
        // 關於密碼的加密,應該是在創建用戶的時候進行的,這裏僅僅是舉例模擬
        return new User(username, encodedPassword,
                enabled, accountNonExpired,
                credentialsNonExpired, accountNonLocked,
                AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
    }
}

這裏沒有做數據庫的查詢操作,數據都是固定數據,也就是說輸入任何用戶名和指定的密碼123456都是可以進行登錄的。在實際的開發過程中,對於存入到數據庫的密碼,都是經過加密的,所以這裏使用的固定密碼假設是從數據庫查詢到的,然後對它進行加密。從數據庫查詢到的數據進行處理後封裝到User的構造方法中,然後Spring Security就會將User對象和輸入的密碼進行比較,如果有任何問題,就會及時給前端進行提示。啓動Spring Boot應用,訪問任何API,比如http://localhost:8080/user,就會提示要求你輸入密碼。其中PasswordEncoder的實現類對象必須經過配置,如下所示:

/**
 * 配置了這個Bean以後,從前端傳遞過來的密碼將被加密
 *
 * @return PasswordEncoder實現類對象
 */
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

配置了這個Bean以後,從前端傳遞過來的密碼就會被加密,所以從數據庫查詢到的密碼必須是經過加密的,而這個過程都是在用戶註冊的時候進行加密的。這就合理解釋了爲什麼對上面的代碼進行加密了。

三、個性化用戶認證流程

在實際的開發中,對於用戶的登錄認證,不可能使用Spring Security自帶的方式或者頁面,需要自己定製適用於項目的登錄流程。這裏要開發一個模塊,支持用戶在配置文件中配置自己的登錄頁面,如果用戶配置了,則採用用戶自己的頁面,否則採用模塊內置的登錄頁面。

1)自定義登錄頁面

對於用戶自定義的登錄行爲,往往是登錄後跳轉或者是登錄後返回提示用戶簽到等信息,開發者要編寫一個類來繼承WebSecurityConfigurerAdapter從而實現自定義的登錄行爲,並且要重寫configure方法。這裏先把代碼貼出來,然後逐一說明。把這個類編寫在項目lemon-security-browser中,定義一個包com.lemon.security.browser

package com.lemon.security.browser;

import com.lemon.security.core.properties.SecurityProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;

/**
 * 瀏覽器安全驗證的配置類
 *
 * @author lemon
 * @date 2018/4/3 下午7:35
 */
@Configuration
public class BrowserSecurityConfig extends WebSecurityConfigurerAdapter {

    private final SecurityProperties securityProperties;
    private final AuthenticationSuccessHandler lemonAuthenticationSuccessHandler;
    private final AuthenticationFailureHandler lemonAuthenticationFailureHandler;

    @Autowired
    public BrowserSecurityConfig(SecurityProperties securityProperties, AuthenticationSuccessHandler lemonAuthenticationSuccessHandler, AuthenticationFailureHandler lemonAuthenticationFailureHandler) {
        this.securityProperties = securityProperties;
        this.lemonAuthenticationSuccessHandler = lemonAuthenticationSuccessHandler;
        this.lemonAuthenticationFailureHandler = lemonAuthenticationFailureHandler;
    }

    /**
     * 配置了這個Bean以後,從前端傳遞過來的密碼將被加密
     *
     * @return PasswordEncoder實現類對象
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                .loginPage("/authentication/require")
                .loginProcessingUrl("/authentication/form")
                .successHandler(lemonAuthenticationSuccessHandler)
                .failureHandler(lemonAuthenticationFailureHandler)
                .and()
                .authorizeRequests()
                .antMatchers("/authentication/require", securityProperties.getBrowser().getLoginPage()).permitAll()
                .anyRequest()
                .authenticated()
                .and()
                .csrf().disable();
    }
}

現在主要講解重寫的configure方法:

  • http.formLogin()指定的表單登錄方式。

  • loginPage("/authentication/require")設置了登錄頁面,這裏將URL指向了一個Controller,這個Controller可以根據用戶的設置選擇傳遞JSON數據還是返回一個登錄頁面。

  • loginProcessingUrl("/authentication/form")是更改了UsernamePasswordAuthenticationFilter默認的處理表單登錄的/loginAPI,現在前端的form標籤的action就可以寫/authentication/form而不是固定的/login

  • successHandler(lemonAuthenticationSuccessHandler)指定了登錄成功後的處理邏輯,一般都是跳轉或者返回一個JSON數據。

  • failureHandler(lemonAuthenticationFailureHandler)指定了登錄失敗後的處理邏輯,一般是是跳轉或者返回一個JSON數據。

  • antMatchers("/authentication/require", securityProperties.getBrowser().getLoginPage()).permitAll()意思是指/authentication/require和登錄頁面的請求無需驗證權限。

  • csrf().disable()是指關閉跨站請求僞造的防護,這裏是爲了前期開發方便,關閉它。

整體描述:當用戶訪問系統的RESTful API的時候,第一次訪問會檢查當前訪問的用戶有沒有權限訪問,如果沒有權限,就會進入到BrowserSecurityConfig的configure方法中,從而進入到/authentication/requireController方法中判斷用戶是否是訪問HTML,如果是則跳轉到登陸頁面,否則返回一段JSON數據提示用戶登錄。這裏還自定義配置了用戶登陸成功和失敗的處理邏輯,對於/authentication/require和登錄頁面的請求則無需驗證權限,否則將陷進死循環中。

根據/authentication/require,我們編寫一個Controller,來控制是跳轉到登陸頁面還是返回一段JSON,代碼如下:

package com.lemon.security.browser;

import com.lemon.security.browser.support.SimpleResponse;
import com.lemon.security.core.properties.SecurityProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @author lemon
 * @date 2018/4/5 下午2:25
 */
@RestController
@Slf4j
public class BrowserSecurityController {

    private RequestCache requestCache = new HttpSessionRequestCache();

    private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();

    private static final String HTML = ".html";

    private final SecurityProperties securityProperties;

    @Autowired
    public BrowserSecurityController(SecurityProperties securityProperties) {
        this.securityProperties = securityProperties;
    }

    /**
     * 當需要進行身份認證的時候跳轉到此方法
     *
     * @param request  請求
     * @param response 響應
     * @return 將信息以JSON形式返回給前端
     */
    @RequestMapping("/authentication/require")
    @ResponseStatus(code = HttpStatus.UNAUTHORIZED)
    public SimpleResponse requireAuthentication(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 從session緩存中獲取引發跳轉的請求
        SavedRequest savedRequest = requestCache.getRequest(request, response);
        if (null != savedRequest) {
            String redirectUrl = savedRequest.getRedirectUrl();
            log.info("引發跳轉的請求是:{}", redirectUrl);
            if (StringUtils.endsWithIgnoreCase(redirectUrl, HTML)) {
                // 如果是HTML請求,那麼就直接跳轉到HTML,不再執行後面的代碼
                redirectStrategy.sendRedirect(request, response, securityProperties.getBrowser().getLoginPage());
            }
        }
        return new SimpleResponse("訪問的服務需要身份認證,請引導用戶到登錄頁面");
    }
}

當用戶沒有登錄就訪問某些API的時候,就會被引導進入此Controller,這裏僅僅是模擬了用戶如果是訪問的HTML的話,就引導它到登錄頁面,如果是AJAX發送的請求的,往往需要返回JSON數據到前端。當用戶訪問的是HTML的時候,securityProperties.getBrowser().getLoginPage()就決定了用戶是跳轉到自定義的登錄頁面,還是此項目中自帶的登錄頁面中。請看下面的配置類:

package com.lemon.security.core.properties;

import lombok.Data;

/**
 * @author lemon
 * @date 2018/4/5 下午3:08
 */
@Data
public class BrowserProperties {

    private String loginPage = "/login.html";

    private LoginType loginType = LoginType.JSON;
}

這裏提供的是項目中自帶的登錄頁面,在loginPage變量中給定了默認值,那麼這個頁面就在lemon-security-browserresourcesresources的文件夾內。對於自定義的登錄頁面,通過下面的代碼從配置文件中讀取:

package com.lemon.security.core.properties;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

/**
 * @author lemon
 * @date 2018/4/5 下午3:08
 */
@Data
@ConfigurationProperties(prefix = "com.lemon.security")
public class SecurityProperties {

    private BrowserProperties browser = new BrowserProperties();
}

爲了使這個讀取配置的類生效,需要寫一個類:

package com.lemon.security.core;

import com.lemon.security.core.properties.SecurityProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;

/**
 * @author lemon
 * @date 2018/4/5 下午3:11
 */
@Configuration
@EnableConfigurationProperties(SecurityProperties.class)
public class SecurityCoreConfig {
}

以上代碼基本完成了登錄的基本功能,當用戶訪問的是HTML的時候,就會跳轉到登錄頁面,如果是RESTful API的時候,返回一段JSON數據,前端可以根據JSON數據來提示用戶登錄。至於用戶自定義界面,可以在application.yml配置,具體的配置如下:

# 配置自定義的登錄頁面
com:
  lemon:
    security:
      browser:
        loginPage: /lemon-login.html

2)自定義用戶登錄成功處理

用戶登錄成功後,Spring Security的默認處理方式是跳轉到原來的鏈接上,這也是企業級開發的常見方式,但是有時候採用的是AJAX方式發送的請求,往往需要返回JSON數據,所以這裏給出了簡單的登錄成功的案例:

package com.lemon.security.core.authentication;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.lemon.security.core.properties.LoginType;
import com.lemon.security.core.properties.SecurityProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * {@link SavedRequestAwareAuthenticationSuccessHandler}是Spring Security默認的成功處理器
 *
 * @author lemon
 * @date 2018/4/5 下午7:42
 */
@Component("lemonAuthenticationSuccessHandler")
@Slf4j
public class LemonAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {

    private final ObjectMapper objectMapper;
    private final SecurityProperties securityProperties;

    @Autowired
    public LemonAuthenticationSuccessHandler(ObjectMapper objectMapper, SecurityProperties securityProperties) {
        this.objectMapper = objectMapper;
        this.securityProperties = securityProperties;
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        log.info("登錄成功");
        if (LoginType.JSON.equals(securityProperties.getBrowser().getLoginType())) {
            // 如果用戶自定義了處理成功後返回JSON(默認方式也是JSON),那麼這裏就返回JSON
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write(objectMapper.writeValueAsString(authentication));
        } else {
            // 如果用戶定義的是跳轉,那麼就使用父類方法進行跳轉
            super.onAuthenticationSuccess(request, response, authentication);
        }
    }
}

SavedRequestAwareAuthenticationSuccessHandlerSpring Security默認的成功處理器,默認是跳轉。這裏將認證信息作爲JSON數據進行了返回,也可以返回其他數據,這個是根據業務需求來定的,同樣,這裏也是配置了用戶的自定義的登錄類型,要麼是跳轉,要麼是JSONsecurityProperties.getBrowser().getLoginType()決定了登錄的類型,默認是JSON,如果需要跳轉,也是需要在YAML配置文件中進行配置的。

# 配置自定義成功和錯誤處理方式
com:
  lemon:
    security:
      browser:
        loginType: REDIRECT

爲了使自定義的成功處理器生效,需要在BrowserSecurityConfig中進行配置,前面的代碼中已經進行了配置。

3)自定義用戶登錄失敗處理

同樣,如果登錄失敗,也需要自定義登錄失敗處理器,代碼如下:

package com.lemon.security.core.authentication;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.lemon.security.core.properties.LoginType;
import com.lemon.security.core.properties.SecurityProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * {@link SimpleUrlAuthenticationFailureHandler}是Spring Boot默認的失敗處理器
 *
 * @author lemon
 * @date 2018/4/5 下午7:51
 */
@Component("lemonAuthenticationFailureHandler")
@Slf4j
public class LemonAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    private final ObjectMapper objectMapper;
    private final SecurityProperties securityProperties;

    @Autowired
    public LemonAuthenticationFailureHandler(ObjectMapper objectMapper, SecurityProperties securityProperties) {
        this.objectMapper = objectMapper;
        this.securityProperties = securityProperties;
    }
    
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        log.info("登錄失敗");
        if (LoginType.JSON.equals(securityProperties.getBrowser().getLoginType())) {
            response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write(objectMapper.writeValueAsString(exception));
        } else {
            // 如果用戶配置爲跳轉,則跳到Spring Boot默認的錯誤頁面
            super.onAuthenticationFailure(request, response, exception);
        }
    }
}

配置方法和登錄成功的方法一致。

Spring Security技術棧開發企業級認證與授權系列文章列表:

Spring Security技術棧學習筆記(一)環境搭建
Spring Security技術棧學習筆記(二)RESTful API詳解
Spring Security技術棧學習筆記(三)表單校驗以及自定義校驗註解開發
Spring Security技術棧學習筆記(四)RESTful API服務異常處理
Spring Security技術棧學習筆記(五)使用Filter、Interceptor和AOP攔截REST服務
Spring Security技術棧學習筆記(六)使用REST方式處理文件服務
Spring Security技術棧學習筆記(七)使用Swagger自動生成API文檔
Spring Security技術棧學習筆記(八)Spring Security的基本運行原理與個性化登錄實現
Spring Security技術棧學習筆記(九)開發圖形驗證碼接口
Spring Security技術棧學習筆記(十)開發記住我功能
Spring Security技術棧學習筆記(十一)開發短信驗證碼登錄
Spring Security技術棧學習筆記(十二)將短信驗證碼驗證方式集成到Spring Security
Spring Security技術棧學習筆記(十三)Spring Social集成第三方登錄驗證開發流程介紹
Spring Security技術棧學習筆記(十四)使用Spring Social集成QQ登錄驗證方式
Spring Security技術棧學習筆記(十五)解決Spring Social集成QQ登錄後的註冊問題
Spring Security技術棧學習筆記(十六)使用Spring Social集成微信登錄驗證方式

示例代碼下載地址:

項目已經上傳到碼雲,歡迎下載,內容所在文件夾爲chapter008

更多幹貨分享,歡迎關注我的微信公衆號:爪哇論劍(微信號:itlemon)
在這裏插入圖片描述

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