springboot security5 + vue 前後分離json方式登入(包括remember-me)實現
由於security只支援formData方式提交表單,前端請求axios post為json格式資料,所以產生security接收不到登入引數(使用者,密碼,自動登入,驗證碼...都接收不到。ps:spring security應該升級這塊功能了)。
問題已經很明確了,接下來就是想辦法解決了。
查了下前端axios也可以用formdata我試了下後端接收不到資料,於是又查又說要使用QS將引數拼接到url上去,結果還是接收不到資料,最後果斷放棄前端修改,直接改後端接收方式。(前端大佬看見了可以教教我改前端^_^)
security是由一系列的攔截器實現的,每個攔截器都有各自的功能。如果我們程式請求規則和security完全一致的話,security原始碼不用改動完全可以滿足登入需求,當security預設的攔截器不能滿足我們的要求時,哪個攔截器不滿足需求,我們就重寫哪個攔截器即可。(自定義的攔截器不能重複建立物件,裝配一次即可。)
例如:賬號密碼解析攔截器UsernamePasswordAuthenticationFilter 用來解析request請求中封裝的賬號密碼資訊。
// UsernamePasswordAuthenticationFilter 原始碼中會執行這個方法 public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (this.postOnly && !request.getMethod().equals("POST")) { throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); } else { // 解析使用者密碼 String username = this.obtainUsername(request); String password = this.obtainPassword(request); if (username == null) { username = ""; } if (password == null) { password = ""; } username = username.trim(); UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password); this.setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); } } // 解析方式是request.getParameter()只有這種方式,這種方式並不能解析json表單 @Nullable protected String obtainUsername(HttpServletRequest request) { return request.getParameter(this.usernameParameter); }
當我們知道這個攔截器是處理請求中賬號密碼引數的就可以重寫這個攔截器,然後將我們重寫的攔截器裝配到SecurityConfig中即可。
1:重寫UsernamePasswordAuthenticationFilter
/** * json形式登入過濾 */ public class MyUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter { @Autowired @Override public void setAuthenticationManager(AuthenticationManager authenticationManager) { super.setAuthenticationManager(authenticationManager); } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (!request.getMethod().equals("POST")) { throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod()); } if (request.getContentType().equals(MediaType.APPLICATION_JSON_UTF8_VALUE) || request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE)) { // 說明使用者以 JSON 的形式傳遞的引數 String username = null; String password = null; String verifyCode = null; Boolean rememberMe = null; try { Map<String, String> map = new ObjectMapper().readValue(request.getInputStream(), Map.class); username = map.get("username").trim(); password = map.get("password").trim(); verifyCode = map.get("verifyCode").trim(); rememberMe = (Boolean) ( (Object) map.get("rememberMe")); } catch (IOException e) { e.printStackTrace(); } // request.getInputStream()流讀取一次就清空了 // 為了防止之後會頻繁的使用表單中的引數,一次性全部將表單內容寫入到attribute中去 // 即使多次使用引數我們直接getAttribute就可以拿到引數不用每次都使用流(也獲取不到流了,會報流已關閉異常) request.setAttribute("username",username); request.setAttribute("password",password); request.setAttribute("verifyCode",verifyCode); request.setAttribute("rememberMe",rememberMe); UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken( username, password); // Allow subclasses to set the "details" property setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); } return super.attemptAuthentication(request, response); } }
2:將重寫的攔截器裝配到SecurityConfig中,以下是SecurityConfig 中我使用的一部分配置。(裡面注入的有的程式碼我沒有貼,登入成功/失敗/異常/token失效等攔截器這種網上有很多,就沒有貼)
首先建立MyUsernamePasswordAuthenticationFilter例項
然後在configure(HttpSecurity http) 方法中追加配置:http.addFilterAt(myAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
我們自定義的json格式表單賬號密碼攔截器裝配就完成了。
package com.lkj.manager.config.securityConfig;
import com.lkj.manager.config.request.WrapRequestFilter;
import com.lkj.manager.config.securityConfig.exception.MyAccessDeniedHandler;
import com.lkj.manager.config.securityConfig.handler.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;
import org.springframework.security.web.authentication.RememberMeServices;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationFilter;
import org.springframework.security.web.csrf.CsrfFilter;
import org.springframework.web.filter.CharacterEncodingFilter;
import javax.sql.DataSource;
@Slf4j
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
MyLogoutSuccessHandler myLogoutSuccessHandler;
@Autowired
MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;
@Autowired
MyAuthenticationFailedHandler myAuthenticationFailedHandler;
@Autowired
MyAccessDeniedHandler myAccessDeniedHandler;
@Autowired
MyAuthenticationEntryPoint myAuthenticationEntryPoint;
@Autowired
DataSource dataSource;
@Bean
//註冊UserDetailsService 的bean
MyUserDetailService wjcUserDetailsService(){
return new MyUserDetailService();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//user Details Service驗證
//密碼加密,與資料庫匹配 auth.userDetailsService(wjcUserDetailsService()).passwordEncoder(BCryptPasswordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/ueditor/**","/login", "/getVerifyCode", "/websocket").permitAll()
.anyRequest().authenticated()
.and()
.exceptionHandling()
.accessDeniedHandler(myAccessDeniedHandler) // 自定義異常處理
//未登入時,進行json格式的提示
.authenticationEntryPoint(myAuthenticationEntryPoint)
.and()
.rememberMe() // 開啟rememberMe功能
.and()
.formLogin()
.loginProcessingUrl("/login") //登入請求
.successHandler(myAuthenticationSuccessHandler)
.failureHandler(myAuthenticationFailedHandler)
.and()
.authorizeRequests()
.and()
.cors().and().csrf().disable() // 開啟跨域訪問
.sessionManagement()//session管理
.and()
.logout()//退出
.logoutUrl("/logout")
.deleteCookies("JESSIONID")
.logoutSuccessHandler(myLogoutSuccessHandler)
.permitAll(); //登出行為任意訪問
http.addFilterBefore(myFilterSecurityInterceptor, FilterSecurityInterceptor.class);
http.addFilterBefore(new WrapRequestFilter(),UsernamePasswordAuthenticationFilter.class);
http.addFilterAt(myAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
http.addFilterAt(rememberMeAuthenticationFilter(), RememberMeAuthenticationFilter.class);
}
/**
* 注入密碼編解碼
* @return
*/
@Bean
public BCryptPasswordEncoder BCryptPasswordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
/**
* 配置TokenRepository
* @return
*/
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
// jdbcTokenRepository.setCreateTableOnStartup(true);
return jdbcTokenRepository;
}
@Bean
MyUsernamePasswordAuthenticationFilter myAuthenticationFilter() throws Exception {
MyUsernamePasswordAuthenticationFilter filter = new MyUsernamePasswordAuthenticationFilter();
filter.setAuthenticationSuccessHandler(myAuthenticationSuccessHandler);
filter.setAuthenticationFailureHandler(myAuthenticationFailedHandler);
filter.setAuthenticationManager(authenticationManager());
filter.setRememberMeServices(rememberMeServices());
return filter;
}
@Bean
public RememberMeServices rememberMeServices() {
MyTokenBasedRememberMeServices rememberMeServices = new MyTokenBasedRememberMeServices("INTERNAL_SECRET_KEY", wjcUserDetailsService(), persistentTokenRepository());
rememberMeServices.setParameter("rememberMe"); // 修改預設引數remember-me為rememberMe和前端請求中的key要一致
rememberMeServices.setTokenValiditySeconds(3600 * 24 * 7); //token有效期7天
return rememberMeServices;
}
@Bean
public RememberMeAuthenticationFilter rememberMeAuthenticationFilter() throws Exception {
//重用WebSecurityConfigurerAdapter配置的AuthenticationManager,不然要自己組裝AuthenticationManager
return new RememberMeAuthenticationFilter(authenticationManager(), rememberMeServices());
}
@Bean
@Override
protected AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
}
json中的賬號密碼資料拿到了會走UserDetailsService中的loadUserByUsername()方法查詢使用者相關資訊(驗證這塊就不詳細說明,不是本文重點,網上例子也很多),賬號密碼驗證碼都對比成功登陸成功後為了使用者方便下次免登陸remember-me功能就是接下來要security要做的事情,在SecurityConfig中我們已經開啟了rememberMe()功能,所以登陸成功後就會進行記住我操作,儲存token的方式有很多種,這裡我們選擇使用資料庫持久化的方式來儲存token(token的生成由賬號密碼組成如果直接返回會有安全問題,資料庫持久化方式返回的是remember-me資訊,如果request中攜帶該資訊,security會查詢資料庫解析remember-me資訊對比資料庫中是否存在token,如果沒有去登入,如果有直接跳過登入)remember-me中是不含敏感資訊所以相對安全。
所以我們選擇remember-me方式來進行免登入,和賬號密碼一樣,原始碼中rememberMe獲取方式也不支援json。security為我們提供了一個RememberMeServices 介面。
public interface RememberMeServices {
Authentication autoLogin(HttpServletRequest var1, HttpServletResponse var2);
void loginFail(HttpServletRequest var1, HttpServletResponse var2);
void loginSuccess(HttpServletRequest var1, HttpServletResponse var2, Authentication var3);
}
然後我們繼續往下扒原始碼發現AbstractRememberMeServices實現了RememberMeServices介面,從名字看出來AbstractRememberMeServices是個抽象類,這個類中封裝了關於remember-me功能的一些常用操作。例如:登入時記住token,退出時刪除token,token失效時刪除token,操作cookie等等。這是個抽象類所有下面肯定還會有子類,果然我們發現下面就剩下 TokenBasedRememberMeServicesPersistentTokenBasedRememberMeServices這2個子類了。經過我bug追蹤發現TokenBasedRememberMeServices 判斷完remember-me後並沒有進行儲存資料庫操作,而PersistentTokenBasedRememberMeServices在判斷完remember-me後進行了資料庫操作。(猜想:TokenBasedRememberMeServices 可能是預留給開發人員自己靈活處理token存放位置的類,如果token不存資料庫的話,開發人員可將token存在其它地方。PersistentTokenBasedRememberMeServices會直接將開啟remember-me功能的token資訊存到資料庫中)。
所以PersistentTokenBasedRememberMeServices類滿足我們的需求,我們直接繼承該類複寫自己的攔截器獲取json表單中攜帶的remember-me引數。
建立remember-me的攔截器 MyPersistentTokenBasedRememberMeServices
public class MyPersistentTokenBasedRememberMeServices extends PersistentTokenBasedRememberMeServices {
private boolean alwaysRemember;
public void setAlwaysRemember(boolean alwaysRemember) {
this.alwaysRemember = alwaysRemember;
}
public MyPersistentTokenBasedRememberMeServices(String key, UserDetailsService userDetailsService, PersistentTokenRepository jdbcTokenRepositoryImpl) {
super(key, userDetailsService,jdbcTokenRepositoryImpl);
}
@Override
protected boolean rememberMeRequested(HttpServletRequest request, String parameter) {
if (alwaysRemember) {
return true;
}
//判斷請求是否為JSON
if(request != null && request.getMethod().equalsIgnoreCase("POST") && request.getContentType() != null &&
(request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_UTF8_VALUE) || request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_VALUE))) {
// 此時我們之前在賬號密碼攔截器中向Attribute中放的資料可以再次取出來使用了
// 如果使用request.getInputStream()獲取流會發現流已經關閉會報錯
Boolean rememberMe =(Boolean) request.getAttribute("rememberMe");
if(rememberMe){
return true;
}
}
//否則呼叫原本的自我記住功能
return super.rememberMeRequested(request, parameter);
}
}
建立json表單的remember-me攔截器成功後,我們就可以在SecurityConfig中裝配攔截器。(上面SecurityConfig已經貼有裝配的程式碼)。
(這篇主要寫的關於解決前後分離json方式登入時,security接收不到引數的問題,並不是很全面的登入流程程式碼,關鍵是解決這一問題的思路,哪個攔截器處理不了問題我們就修改哪個攔截器,修改完後裝配到配置中去。json格式登入目前發現的問題都已解決,如果把上述程式碼ctrl+c ctrl+v 完後在你的專案中不一定能執行(程式碼不全),補全其它程式碼後加上以上程式碼json格式登入的問題,全部都已解決。即使還有沒解決的問題,按照同樣的思路,先找到security對應功能的預設攔截器,再重寫該攔截器,最後再裝配都是一樣的原理)