Spring Security 4 原理詳解
場景:
- 使用者通過提交username和password請求登陸
- 伺服器驗證身份資訊是否正確
- 獲取使用者資訊(包括角色集合)
- 利用3中的資訊構建Security Context
- 在此之後,該使用者的所有請求,Spring Security的訪問控制機制將根據Security Context中的資訊判斷使用者是否具有許可權
以上,前3點即為認證,構建Security Context,為之後5的授權(訪問控制)鋪墊,SecurityContext中的使用者資訊也可供應用程式使用。
Spring Security的幾個核心元件
1、SecurityContextHolder
用於儲存的Security Context,主要是當前會話的Principal
常用於獲取當前使用者的相關資訊,Spring Security使用
Authentication
類來封裝這些資訊,獲取當前會話使用者的使用者名稱程式碼如下所示:
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principal instanceof UserDetails) {
String username = ((UserDetails)principal).getUsername();
} else {
String username = principal.toString();
}
程式碼中的getContext()
方法的返回值是一個SecurityContext
的例項,就是在完成認證之後儲存在ThreadLocal中的物件,主要封裝了使用者的相關資訊。
2、UserDetails
、Principal
、Authentication
Spring Security中使用Authentication
儲存當前使用者的主要資訊。
Authentication
中儲存的是Principal
,與UserDetails
直接可以 強制轉換。
UserDetails
Principal
的介面卡。 UserDetails
的原始碼如下所示:
public abstract interface UserDetails extends Serializable {
public abstract Collection<? extends GrantedAuthority> getAuthorities();
public abstract String getPassword();
public abstract String getUsername();
public abstract boolean isAccountNonExpired();
public abstract boolean isAccountNonLocked();
public abstract boolean isCredentialsNonExpired();
public abstract boolean isEnabled();
}
由原始碼可知其主要是使用者名稱、密碼、許可權列表等資訊;因此,通常可以在專案定義個自己的使用者物件並實現該介面,重寫其getAuthorities()方法即可。目的是為了讓專案中具體業務邏輯中的許可權資訊與Spring Security所需的許可權配置對應上。舉例部分程式碼,省略getter與setter方法:
public class UserPo implements UserDetails{
private String userNo;
private String username;
private String password;
private List<RolePo> roles;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<GrantedAuthority> auths = new ArrayList<GrantedAuthority>();
List<RolePo> roles = this.getRoles();
for(RolePo rp : roles){
auths.add(new SimpleGrantedAuthority(rp.getRoleName()));
}
return auths;
}
}
Spring Security提供一個UserDetailsService
介面,該介面用於查詢並返回一個UserDetails
。只要我們實現了該介面,並注入到某Provider中,Spring Security就會自動使用該Service做查詢。舉例:
public class UserService implements UserDetailsService{
@Autowired
UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserPo userPo = userMapper.queryUsernameAndPassword(username);
List<RolePo> roles = userMapper.queryRolesByUsername(username);
userPo.setRoles(roles);
return userPo;
}
}
以上方法中,我們實現了UserDetailsService
的loadUserByUsername(String username)
介面,根據使用者名稱去資料庫查詢使用者名稱、密碼及角色資訊,之後的認證過程中,該方法會自動執行,後續會解釋。當前只需要知道,UserDetails
、Principal
、Authentication
以及UserDetailsService
之間的關係即可。
3、GrantedAuthority
Authentication
物件除了getPrincipal()
方法返回使用者基本資訊外,還有getAuthorities()
方法可以返回List<GrantedAuthority>
,即使用者的許可權相關資訊,通常是一些角色資訊。
在第2點中UserDetails
需要重寫的部分重點就是為了把專案中的許可權與Spring Security中的需要校驗的許可權做個轉換。
有了List<GrantedAuthority>
,Spring Security的訪問控制機制就能判斷使用者的請求是否具有許可權了。
Spring Security的認證與授權實現:
1、首先需要一個認證管理器AuthenticationManager
,它將註冊一個ProviderManager
例項;
2、其次要註冊一個或多個認證provider,即相關的認證邏輯,認證管理器會依次執行這些provider;
3、這些provider執行之後認證管理器將返回一個完整的Authentication
或者是認證失敗的異常;
4、假設3中的認證成功之後,Authentication
將會被儲存的Spring Context中,之後該使用者的所有訪問,都會根據該Authentication
中的List<GrantedAuthority>
判斷是否具有許可權。
Spring Security提供了一個最簡單的認證provider的實現:DaoAuthenticationProvider
。該provider可以呼叫2中的UserDetailsService
根據使用者提交的登陸表單中的使用者名稱查詢該使用者的資訊,並且自動完成使用者資訊中的密碼與登陸表單中的密碼的比對完成校驗。
舉個例子:
宣告一個認證管理器並注入provider,可在xml中配置,如下所示:
<authentication-manager>
<authentication-provider ref="daoAuthenticationProvider" />
</authentication-manager>
<bean id="daoAuthenticationProvider"
class="org.springframework.security.authentication.dao.DaoAuthenticationProvider">
<property name="userDetailsService" ref="userService"/>
</bean>
其中userService
即之前提到的重寫了UserDetailsService
介面中loadUserByUsername
方法的服務類
然後配置訪問控制的策略,可在XML中配置,如下所示:
<intercept-url pattern="/home/*" access="hasRole('MAIN_FRAME')"/>
表示擁有’MAIN_FRAME’角色的使用者才可訪問/home/下的資源,還有很多種配置策略,可以根據自己的需求配置。
配置登陸表單屬性:
<form-login login-page='/home/login' default-target-url='/home/show'
always-use-default-target="true"
authentication-failure-url="/home/login?error"/>
表單屬性的配置對認證攔截的URL有影響,在後文詳將細描述。
以上XML的配置程式碼,在Spring boot 專案中也可如下程式碼一樣配置,功能都一樣:
@Configuration
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter{
/**
* 裝配一個UserService
*/
@Bean
UserService initUserService(){
return new UserService();
}
/**
* 配置一個AuthenticationManager並使用DaoAuthenticationProvider做登陸認證
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(initUserService());
}
/**
* 配置訪問控制的策略
* 其中/hello資源需要使用者擁有‘經辦1組’許可權
* 登陸頁面為響應/login.do的controller(Spring MVC相關)返回的具體檢視
* provider處理的表單其Action為'/loginaaa',如果不設定預設是'/login',
* 此處為了與controller響應的請求做區分所以簡單配置說明
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/hello")
.hasAuthority("經辦1組")
.and()
.formLogin()
.loginPage("/login")
.successForwardUrl("/success")
.loginProcessingUrl("/loginaaa")
.permitAll();
}
}
做了以上的配置之後,我們只需要一個帶有form表單,並且form中的Action路徑為‘loginaaa’的html,就可以完成整個登陸的流程了。流程如圖所示:
原理:
在Spring Security4中,我們在定義formlogin
物件的時候會new一個UsernamePasswordAuthenticationFilter
:
public FormLoginConfigurer() {
super(new UsernamePasswordAuthenticationFilter(), null);
usernameParameter("username");
passwordParameter("password");
}
UsernamePasswordAuthenticationFilter繼承了AbstractAuthenticationProcessingFilter,呼叫了父類的構造器:
public UsernamePasswordAuthenticationFilter() {
super(new AntPathRequestMatcher("/login", "POST"));
}
所以在Spring Security 4 中,攔截器預設是會攔截/login
請求的,因為該父類的構造方法部分原始碼如下所示
protected AbstractAuthenticationProcessingFilter(RequestMatcher requiresAuthenticationRequestMatcher) {
Assert.notNull(requiresAuthenticationRequestMatcher, "requiresAuthenticationRequestMatcher cannot be null");
this.requiresAuthenticationRequestMatcher = requiresAuthenticationRequestMatcher;
}
protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) {
return this.requiresAuthenticationRequestMatcher.matches(request);
}
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
if (!(requiresAuthentication(request, response))) {
chain.doFilter(request, response);
return;
}
if (this.logger.isDebugEnabled()) {
this.logger.debug("Request is to process authentication");
}
try {
Authentication authResult = attemptAuthentication(request, response);
if (authResult == null) {
return;
}
this.sessionStrategy.onAuthentication(authResult, request, response);
} catch (InternalAuthenticationServiceException failed) {
this.logger.error("An internal error occurred while trying to authenticate the user.", failed);
unsuccessfulAuthentication(request, response, failed);
return;
} catch (AuthenticationException failed) {
unsuccessfulAuthentication(request, response, failed);
return;
}
Authentication authResult;
if (this.continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
successfulAuthentication(request, response, cha/loginResult);
}
所以登陸表單的action 需要配置為/login
,否則就需要呼叫loginProcessingUrl()
方法進行配置,例子中我們配置了/loginaaa
,所以form表單的action就要相應的改為/loginaaa
,要與返回登陸頁面檢視的相應/login的controller區分開。這就是為什麼我們的登陸表單的請求會被攔截並特殊的交由Spring Security
的AuthenticationManager
處理。
之前的配置中
auth.userDetailsService(initUserService())
的實現如下所示:
public <T extends UserDetailsService> DaoAuthenticationConfigurer<AuthenticationManagerBuilder, T> userDetailsService(
T userDetailsService) throws Exception {
this.defaultUserDetailsService = userDetailsService;
return ((DaoAuthenticationConfigurer) apply(new DaoAuthenticationConfigurer(userDetailsService)));
}
private DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
private final U userDetailsService;
protected AbstractDaoAuthenticationConfigurer(U userDetailsService) {
this.userDetailsService = userDetailsService;
this.provider.setUserDetailsService(userDetailsService);
}
表示會為DaoAuthenticationProvider
注入我們的UserService
。
在DaoAuthenticationProvider
中,會自動呼叫以下原始碼去使用注入的Userservice
去做查詢操作,並返回一個UserDetails
:
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
UserDetails loadedUser;
try {
loadedUser = getUserDetailsService().loadUserByUsername(username);
} catch (UsernameNotFoundException notFound) {
if (authentication.getCredentials() != null) {
String presentedPassword = authentication.getCredentials().toString();
this.passwordEncoder.isPasswordValid(this.userNotFoundEncodedPassword, presentedPassword, null);
}
throw notFound;
} catch (Exception repositoryProblem) {
throw new InternalAuthenticationServiceException(repositoryProblem.getMessage(), repositoryProblem);
}
UserDetails loadedUser;
if (loadedUser == null) {
throw new InternalAuthenticationServiceException(
"UserDetailsService returned null, which is an interface contract violation");
}
return loadedUser;
}
之後會呼叫以下原始碼去做校驗,即登陸表單的資料與用UserService
查詢到的資料庫中的資料做對比
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication)
/* */ throws AuthenticationException
/* */ {
/* 78 */ Object salt = null;
/* */
/* 80 */ if (this.saltSource != null) {
/* 81 */ salt = this.saltSource.getSalt(userDetails);
/* */ }
/* */
/* 84 */ if (authentication.getCredentials() == null) {
/* 85 */ this.logger.debug("Authentication failed: no credentials provided");
/* */
/* 87 */ throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
/* */
/* */
/* */ }
/* */
/* 92 */ String presentedPassword = authentication.getCredentials().toString();
/* */
/* 94 */ if (this.passwordEncoder.isPasswordValid(userDetails.getPassword(), presentedPassword, salt))
/* */ return;
/* 96 */ this.logger.debug("Authentication failed: password does not match stored value");
/* */
/* 98 */ throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
/* */ }
最後如果認證成功,將會呼叫以下方法返回一個完整的Authentication
到Security Context中,供訪問控制使用:
return createSuccessAuthentication(principalToReturn, authentication, user);
以上提到的是Spring Security中最簡單的一種provider:DaoAuthenticationProvider
,使用這一種模式,我們只需要重寫一個UserDetailsService,幾行配置資訊,加上一個登入表單就可以了。
但是專案中往往要比這複雜,不過原理是不變的,我們可以不要UserDetailsService
,可以自己寫一個MyProvider
,只要在Provider中完成你所需要的校驗,比如使用者是否被鎖,密碼是否正確等等,然後返回一個Authentication
類就可以了,其他還是不變的。