Spring Security 使用JSON格式参数登录的两种方式
前言
在 Spring Security
中,默认的登陆方式是以表单形式进行提交参数的。可以参考前面的几篇文章,但是在前后端分离的项目,前后端都是以 JSON 形式交互的。一般不会使用表单形式提交参数。所以,在 Spring Security
中如果要使用 JSON 格式登录,需要自己来实现。那本文介绍两种方式使用 JSON 登录。
方式一:重写
UsernamePasswordAuthenticationFilter
过滤器方式二:自定义登录接口
方式一
通过前面几篇文章的分析,我们已经知道了登录参数的提取在 UsernamePasswordAuthenticationFilter
过滤器中提取的,因此我们只需要模仿UsernamePasswordAuthenticationFilter
过滤器重写一个过滤器,替代原有的UsernamePasswordAuthenticationFilter
过滤器即可。
UsernamePasswordAuthenticationFilter
的源代码如下:
重写的逻辑如下:
public class LoginFilter extends UsernamePasswordAuthenticationFilter { @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { // 需要是 POST 请求 if (!request.getMethod().equals("POST")) { throw new AuthenticationServiceException( "Authentication method not supported: " + request.getMethod()); } HttpSession session = request.getSession(); // 获得 session 中的 验证码值 String sessionVerifyCode = (String) session.getAttribute("verify_code"); // 判断请求格式是否是 JSON if (request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE) || request.getContentType().equals(MediaType.APPLICATION_JSON_UTF8_VALUE)) { Map<String, String> loginData = new HashMap<>(); try { loginData = new ObjectMapper().readValue(request.getInputStream(), Map.class); } catch (IOException e) { }finally { String code = loginData.get("code"); checkVerifyCode(sessionVerifyCode, code); } String username = loginData.get(getUsernameParameter()); String password = loginData.get(getPasswordParameter()); if(StringUtils.isEmpty(username)){ throw new AuthenticationServiceException("用户名不能为空"); } if(StringUtils.isEmpty(password)){ throw new AuthenticationServiceException("密码不能为空"); } UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken( username, password); setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); }else { checkVerifyCode(sessionVerifyCode, request.getParameter("code")); return super.attemptAuthentication(request, response); } } private void checkVerifyCode(String sessionVerifyCode, String code) { if (StringUtils.isEmpty(code)){ throw new AuthenticationServiceException("验证码不能为空!"); } if(StringUtils.isEmpty(sessionVerifyCode)){ throw new AuthenticationServiceException("请重新申请验证码!"); } if (!sessionVerifyCode.equalsIgnoreCase(code)) { throw new AuthenticationServiceException("验证码错误!"); } } } 复制代码
上述代码逻辑如下:
1、当前登录请求是否是 POST 请求,如果不是,则抛出异常。
2、判断请求格式是否是 JSON,如果是则走我们自定义的逻辑,如果不是则调用
super.attemptAuthentication
方法,进入父类原本的处理逻辑中;当然也可以抛出异常。3、如果是 JSON 请求格式的数据,通过 ObjectMapper 读取 request 中的 I/O 流,将 JSON 映射到Map 上。
4、从 Map 中取出 code key的值,判断验证码是否正确,如果验证码有错,则直接抛出异常。如果对验证码相关逻辑感到疑惑,请前往:【Spring Security 在登录时如何添加图形验证码验证】
5、根据用户名、密码构建
UsernamePasswordAuthenticationToken
对象,然后调用官方的方法进行验证,验证用户名、密码是否真实有效。
接下来就是将我们自定义的 LoginFilter 过滤器代替默认的 UsernamePasswordAuthenticationFilter
。
import cn.cxyxj.study05.filter.config.MyAuthenticationEntryPoint; import cn.cxyxj.study05.filter.config.MyAuthenticationFailureHandler; import cn.cxyxj.study05.filter.config.MyAuthenticationSuccessHandler; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.password.NoOpPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Bean PasswordEncoder passwordEncoder() { return NoOpPasswordEncoder.getInstance(); } @Bean @Override protected UserDetailsService userDetailsService() { InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager(); manager.createUser(User.withUsername("cxyxj").password("123").roles("admin").build()); manager.createUser(User.withUsername("security").password("security").roles("user").build()); return manager; } @Override @Bean public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Override protected void configure(HttpSecurity http) throws Exception { // 用自定义的 LoginFilter 实例代替 UsernamePasswordAuthenticationFilter http.addFilterBefore(loginFilter(), UsernamePasswordAuthenticationFilter.class); http.authorizeRequests() //开启配置 // 验证码、登录接口放行 .antMatchers("/verify-code","/auth/login").permitAll() .anyRequest() //其他请求 .authenticated().and()//验证 表示其他请求需要登录才能访问 .csrf().disable(); // 禁用 csrf 保护 http.exceptionHandling().authenticationEntryPoint(new MyAuthenticationEntryPoint()); } @Bean LoginFilter loginFilter() throws Exception { LoginFilter loginFilter = new LoginFilter(); loginFilter.setFilterProcessesUrl("/auth/login"); loginFilter.setUsernameParameter("account"); loginFilter.setPasswordParameter("pwd"); loginFilter.setAuthenticationManager(authenticationManagerBean()); loginFilter.setAuthenticationSuccessHandler(new MyAuthenticationSuccessHandler()); loginFilter.setAuthenticationFailureHandler(new MyAuthenticationFailureHandler()); return loginFilter; } } 复制代码
当我们替换了 UsernamePasswordAuthenticationFilter
之后,原本在 SecurityConfig#configure 方法中关于 form 表单的配置就会失效,那些失效的属性,都可以在配置 LoginFilter 实例的时候配置;还需要记得配置AuthenticationManager
,否则启动时会报错。
MyAuthenticationFailureHandler
import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.LockedException; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; /** * 登录失败回调 */ public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException { response.setContentType("application/json;charset=utf-8"); PrintWriter out = response.getWriter(); String msg = ""; if (e instanceof LockedException) { msg = "账户被锁定,请联系管理员!"; } else if (e instanceof BadCredentialsException) { msg = "用户名或者密码输入错误,请重新输入!"; } out.write(e.getMessage()); out.flush(); out.close(); } } 复制代码
MyAuthenticationSuccessHandler
import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; /** * 登录成功回调 */ public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { Object principal = authentication.getPrincipal(); response.setContentType("application/json;charset=utf-8"); PrintWriter out = response.getWriter(); out.write(new ObjectMapper().writeValueAsString(principal)); out.flush(); out.close(); } } 复制代码
MyAuthenticationEntryPoint
import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; /** * 未登录但访问需要登录的接口异常回调 */ public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException { response.setContentType("application/json;charset=utf-8"); PrintWriter out = response.getWriter(); out.write("您未登录,请先登录!"); out.flush(); out.close(); } } 复制代码
测试
提供一个业务接口,该接口需要登录才能访问
@GetMapping("/hello") public String hello(){ return "登录成功访问业务接口"; } 复制代码
OK,启动项目,先访问一下 hello 接口。
接下来先调用验证码接口,然后再访问登录接口,如下:
再次访问业务接口!
方式二
@PostMapping("/doLogin") public Object login(@RequestBody LoginReq req) { String account = req.getAccount(); String pwd = req.getPwd(); String code = req.getCode(); UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(account, pwd); Authentication authentication = authenticationManager.authenticate(authenticationToken); SecurityContextHolder.getContext().setAuthentication(authentication); return authentication.getPrincipal(); } public class LoginReq { private String account; private String pwd; private String code; } 复制代码
方式二就是在我们自己的 Controller 层中,编写一个登录接口,接收用户名、密码、验证码参数。根据用户名、密码构建 UsernamePasswordAuthenticationToken
对象,然后调用官方的方法进行验证,验证用户名、密码是否真实有效;最后将认证对象放入到 Security 的上下文中。就三行代码就实现了简单的登录功能。
import cn.cxyxj.study05.custom.config.MyAuthenticationEntryPoint; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.password.NoOpPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Bean PasswordEncoder passwordEncoder() { return NoOpPasswordEncoder.getInstance(); } @Bean @Override protected UserDetailsService userDetailsService() { InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager(); manager.createUser(User.withUsername("cxyxj").password("123").roles("admin").build()); manager.createUser(User.withUsername("security").password("security").roles("user").build()); return manager; } @Override @Bean public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() //开启配置 // 验证码、登录接口放行 .antMatchers("/verify-code","/doLogin").permitAll() .anyRequest() //其他请求 .authenticated().and()//验证 表示其他请求需要登录才能访问 .csrf().disable(); // 禁用 csrf 保护 http.exceptionHandling().authenticationEntryPoint(new MyAuthenticationEntryPoint()); } } 复制代码
简简单单的配置一下内存用户,接口放行。
MyAuthenticationEntryPoint
import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; /** * 未登录但访问需要登录的接口异常回调 */ public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException { response.setContentType("application/json;charset=utf-8"); PrintWriter out = response.getWriter(); out.write("您未登录,请先登录!"); out.flush(); out.close(); } } 复制代码
测试
还是先来访问一下业务接口,如下:
再访问登录接口,如下:
登录成功之后,访问业务接口,如下:
自定义官方过滤器方式,要重写各种接口,比如失败回调、登录成功回调,因为官方已经将这些逻辑单独抽离出来了。需要对认证流程有一定的了解,不然你都不知道为什么需要实现这个接口。
自定义接口方式,只要写好那几行代码,你就可以在后面自定义自己的逻辑,比如:密码输入错误次数限制,这种方式代码编写起来更流畅一点,不需要这个类写一点代码,那个类写一点代码。
两者之间没有哪种方式更好,看公司、个人的开发习惯吧!但自定义接口方法应该用的会比较多一点,笔者公司用的就是该方式。
作者:程序员小杰
链接:https://juejin.cn/post/7167973579319951374