- 首先大家知道,用户登录的用户名/密码是在 UsernamePasswordAuthenticationFilter 类中处理的,具体的处理代码如下
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
String username = obtainUsername(request);
String password = obtainPassword(request);
//省略
}
protected String obtainPassword(HttpServletRequest request) {
return request.getParameter(passwordParameter);
}
protected String obtainUsername(HttpServletRequest request) {
return request.getParameter(usernameParameter);
}
- 为什么 Spring Security 默认是通过 key/value 的形式来传递登录参数,因为它处理的方式就是 request.getParameter。
- 所以我们要定义成 JSON 的,思路很简单,就是自定义来定义一个过滤器代替 UsernamePasswordAuthenticationFilter ,然后在获取参数的时候,换一种方式就行了。
publicclass LoginFilter extends UsernamePasswordAuthenticationFilter {
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (!request.getMethod().equals("POST")) {
thrownew AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
String verify_code = (String) request.getSession().getAttribute("verify_code");
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");
checkCode(response, code, verify_code);
}
String username = loginData.get(getUsernameParameter());
String password = loginData.get(getPasswordParameter());
if (username == null) {
username = "";
}
if (password == null) {
password = "";
}
username = username.trim();
// 从 Map 中取出 username 和 password,构造 UsernamePasswordAuthenticationToken 对象并作校验
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
username, password);
setDetails(request, authRequest);
returnthis.getAuthenticationManager().authenticate(authRequest);
} else {
checkCode(response, request.getParameter("code"), verify_code);
returnsuper.attemptAuthentication(request, response);
}
}
public void checkCode(HttpServletResponse resp, String code, String verify_code) {
if (code == null || verify_code == null || "".equals(code) || !verify_code.toLowerCase().equals(code.toLowerCase())) {
//验证码不正确
thrownew AuthenticationServiceException("验证码不正确");
}
}
}
- 过滤器定义完成后,接下来用我们自定义的过滤器代替默认的 UsernamePasswordAuthenticationFilter,首先我们需要提供一个 LoginFilter 的实例:
@Bean
LoginFilter loginFilter() throws Exception {
LoginFilter loginFilter = new LoginFilter();
loginFilter.setAuthenticationSuccessHandler(new AuthenticationSuccessHandler() {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
Hr hr = (Hr) authentication.getPrincipal();
hr.setPassword(null);
RespBean ok = RespBean.ok("登录成功!", hr);
String s = new ObjectMapper().writeValueAsString(ok);
out.write(s);
out.flush();
out.close();
}
});
loginFilter.setAuthenticationFailureHandler(new AuthenticationFailureHandler() {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
response.setContentType("application/json;charset=utf-8");
PrintWriter out = response.getWriter();
RespBean respBean = RespBean.error(exception.getMessage());
if (exception instanceof LockedException) {
respBean.setMsg("账户被锁定,请联系管理员!");
} elseif (exception instanceof CredentialsExpiredException) {
respBean.setMsg("密码过期,请联系管理员!");
} elseif (exception instanceof AccountExpiredException) {
respBean.setMsg("账户过期,请联系管理员!");
} elseif (exception instanceof DisabledException) {
respBean.setMsg("账户被禁用,请联系管理员!");
} elseif (exception instanceof BadCredentialsException) {
respBean.setMsg("用户名或者密码输入错误,请重新输入!");
}
out.write(new ObjectMapper().writeValueAsString(respBean));
out.flush();
out.close();
}
});
loginFilter.setAuthenticationManager(authenticationManagerBean());
loginFilter.setFilterProcessesUrl("/doLogin");
return loginFilter;
}
代替了
UsernamePasswordAuthenticationFilter
之后,原本在SecurityConfig#configure
方法中关于 form 表单的配置就会失效,那些失效的属性,都可以在配置 LoginFilter 实例的时候配置。
另外记得配置一个 AuthenticationManager,根据
WebSecurityConfigurerAdapter
中提供的配置即可。
FilterProcessUrl
则可以根据实际情况配置,如果不配置,默认的就是 /login
。
最后,我们用自定义的 LoginFilter 实例代替 UsernamePasswordAuthenticationFilter,如下:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
...
//省略
http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
}
- 之前我们配置登录成功的处理是通过如下两个方法来配置的:
defaultSuccessUrl
successForwardUrl
- 这两个都是配置跳转地址的,适用于前后端不分的开发。除了这两个方法之外,还有一个必杀技,那就是
successHandler。
successHandler
的功能十分强大,甚至已经囊括了 defaultSuccessUrl
和 successForwardUrl
的功能。我们来看一下:
.successHandler((req, resp, authentication) -> {
Object principal = authentication.getPrincipal();
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
out.write(new ObjectMapper().writeValueAsString(principal));
out.flush();
out.close();
})
successHandler
方法的参数是一个 AuthenticationSuccessHandler
对象,这个对象中我们要实现的方法是 onAuthenticationSuccess。
onAuthenticationSuccess 方法有三个参数,分别是:
HttpServletRequest
HttpServletResponse
Authentication
- 有了前两个参数,我们就可以在这里随心所欲的返回数据了。
- 利用
HttpServletRequest
我们可以做服务端跳转 - 利用
HttpServletResponse
我们可以做客户端跳转,当然,也可以返回 JSON 数据。 - 第三个
Authentication
参数则保存了我们刚刚登录成功的用户信息。 - 配置完成后,我们再去登录,就可以看到登录成功的用户信息通过 JSON 返回到前端了
- 登录失败也有一个类似的回调,如下:
.failureHandler((req, resp, e) -> {
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
out.write(e.getMessage());
out.flush();
out.close();
})
- 失败的回调也是三个参数,前两个就不用说了,第三个是一个
Exception
,对于登录失败 - 会有不同的原因,
Exception
中则保存了登录失败的原因,我们可以将之通过 JSON 返回到前端。
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
RespBean respBean = RespBean.error(e.getMessage());
if (e instanceof LockedException) {
respBean.setMsg("账户被锁定,请联系管理员!");
} elseif (e instanceof CredentialsExpiredException) {
respBean.setMsg("密码过期,请联系管理员!");
} elseif (e instanceof AccountExpiredException) {
respBean.setMsg("账户过期,请联系管理员!");
} elseif (e instanceof DisabledException) {
respBean.setMsg("账户被禁用,请联系管理员!");
} elseif (e instanceof BadCredentialsException) {
respBean.setMsg("用户名或者密码输入错误,请重新输入!");
}
out.write(new ObjectMapper().writeValueAsString(respBean));
out.flush();
out.close();
未认证处理方案
- 在前后端分离中,这个逻辑明显是有问题的,如果用户没有登录就访问一个需要认证后才能访问的页面,
- 这个时候,我们不应该让用户重定向到登录页面,而是给用户一个尚未登录的提示,
- 前端收到提示之后,再自行决定页面跳转。
- 要解决这个问题,就涉及到 Spring Security 中的一个接口
- AuthenticationEntryPoint ,该接口有一个实现类:LoginUrlAuthenticationEntryPoint ,该类中有一个方法 commence,如下:
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) {
String redirectUrl = null;
if (useForward) {
if (forceHttps && "http".equals(request.getScheme())) {
redirectUrl = buildHttpsRedirectUrlForRequest(request);
}
if (redirectUrl == null) {
String loginForm = determineUrlToUseForThisRequest(request, response,
authException);
if (logger.isDebugEnabled()) {
logger.debug("Server side forward to: " + loginForm);
}
RequestDispatcher dispatcher = request.getRequestDispatcher(loginForm);
dispatcher.forward(request, response);
return;
}
}
else {
redirectUrl = buildRedirectUrlToLoginPage(request, response, authException);
}
redirectStrategy.sendRedirect(request, response, redirectUrl);
}
- 首先我们从这个方法的注释中就可以看出,这个方法是用来决定到底是要重定向还是要 forward,
- 通过 Debug 追踪,我们发现默认情况下 useForward 的值为 false,所以请求走进了重定向。
.csrf().disable().exceptionHandling()
.authenticationEntryPoint((req, resp, authException) -> {
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
out.write("尚未登录,请先登录");
out.flush();
out.close();
}
);
注销登录成功后返回 JSON 即可,配置如下:
.and()
.logout()
.logoutUrl("/logout")
.logoutSuccessHandler((req, resp, authentication) -> {
resp.setContentType("application/json;charset=utf-8");
PrintWriter out = resp.getWriter();
out.write("注销成功");
out.flush();
out.close();
})
.permitAll()
.and()
这样,注销成功之后,前端收到的也是 JSON 了: