1. 程式人生 > >Spring Security技術棧開發企業級認證與授權(八)Spring Security的基本執行原理與個性化登入實現

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技術棧開發企業級認證與授權系列文章列表:

示例程式碼下載地址:

專案已經上傳到碼雲,歡迎下載,內容所在資料夾為chapter008