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
預設的處理表單登入的/login
的API
,現在前端的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/require
的Controller
方法中判斷使用者是否是訪問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-browser
的resources
的resources
的資料夾內。對於自定義的登入頁面,通過下面的程式碼從配置檔案中讀取:
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);
}
}
}
SavedRequestAwareAuthenticationSuccessHandler
是Spring Security
預設的成功處理器,預設是跳轉。這裡將認證資訊作為JSON
資料進行了返回,也可以返回其他資料,這個是根據業務需求來定的,同樣,這裡也是配置了使用者的自定義的登入型別,要麼是跳轉,要麼是JSON
,securityProperties.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
。