由于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等等。这是个抽象类所有下面肯定还会有子类,果然我们发现下面就剩下 TokenBasedRememberMeServices PersistentTokenBasedRememberMeServices这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对应功能的默认拦截器,再重写该拦截器,最后再装配都是一样的原理)