SpringBoot整合SpringSecurity(三)验证码登陆

上一篇文章我们跟踪源码了解到了security整个的登陆流程,这一篇我们就基于这个流程来做一个自己定义的流程,并与security对接。本篇中介绍的是短信登陆,当然验证码登陆跟这个原理是一样的就不多说了。

本文章代码可以参考 https://gitee.com/Maoxs/security-test中的 security-code

准备页面

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>登录</title>
</head>
<body>
<h3>验证码表单登录</h3>
<table>
<tr>
<td>用户名:</td>
<td><input type="text" autocomplete="off" name="mobile"></td>
</tr>
<tr>
<td>验证码:</td>
<td><input type="password" autocomplete="off" name="sms_code"></td>
</tr>
<tr>
<td colspan="2">
<a href="javascript:void(0);" onclick="sendSms()">获取验证码</a>
<button type="button" onclick="loginSms()">登录</button>
</td>
</tr>
</table>
<h3>普通表单登录</h3>
<table>
<tr>
<td>用户名:</td>
<td><input type="text" autocomplete="off" name="username"></td>
</tr>
<tr>
<td>密码:</td>
<td><input type="password" autocomplete="off" name="password"></td>
</tr>
<tr>
<td colspan="2">
<button type="button" onclick="login()">登录</button>
</td>
</tr>
</table>
<script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.min.js"></script>
<script>

function sendSms() {
window.open('/sms/code?mobile=' + $("input[name=mobile]").val());
}

function loginSms() {
var mobile = $("input[name=mobile]").val();
var smsCode = $("input[name=sms_code]").val();
if (mobile === "" || smsCode === "") {
alert("用户名或密码不能为空");
return;
}
$.ajax({
type: "POST",
url: "/sms/login",
data: {
"mobile": mobile,
"smsCode": smsCode
},
success: function (e) {
console.log(e);
alert("登陆成功")
setTimeout(function () {
location.href = '/hello';
}, 500);
},
error: function (e,a,b) {
console.log(a);
console.log(b);
console.log(e.responseText);
alert("登陆失败zxczxczc")
}
});
}


function login() {
var username = $("input[name=username]").val();
var password = $("input[name=password]").val();
if (username === "" || password === "") {
alert("用户名或密码不能为空");
return;
}
debugger
$.ajax({
type: "POST",
url: "/authentication/form",
data: {
"username": username,
"password": password
},
success: function (e) {
console.log(e);
alert("登陆成功")
setTimeout(function () {
location.href = '/hello';
}, 500);
},
error: function (e,a,b) {
console.log(e.responseText);
alert("登陆失败zxczxczc")
}
});
}


</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>hello</title>
</head>
<body>
<h2>hello world from fulinlin.</h2>
<a href="/logout">退出登录</a>
</body>
</html>
@Controller
public class LoginController {

@RequestMapping("/login")
public String login() {
return "login";
}
@RequestMapping("/hello")
public String hello() {
return "hello";
}
}

发短信的接口

@RestController
@Slf4j
public class SmsController {

@RequestMapping("/sms/code")
public String sms(String mobile, HttpSession session) {
int code = (int) Math.ceil(Math.random() * 9000 + 1000);
Map<String, Object> map = new HashMap<>(16);
map.put("mobile", mobile);
map.put("code", code);
session.setAttribute("smsCode", map);
log.info("{}:为 {} 设置短信验证码:{}", session.getId(), mobile, code);
return "你的手机号"+mobile+"验证码是"+code;
}


}

准备测试用户

public interface IUserService {
SysUser findByUsername(String userName);
}

实现类

@Service
public class UserServiceImpl implements IUserService {

private static final Set<SysUser> users = new HashSet<>();


static {
users.add(new SysUser(1L, "fulin", "123456", Arrays.asList("ROLE_ADMIN", "ROLE_DOCKER")));
users.add(new SysUser(2L, "xiaohan", "123456", Arrays.asList("ROLE_ADMIN", "ROLE_DOCKER")));
users.add(new SysUser(3L, "longlong", "123456", Arrays.asList("ROLE_ADMIN", "ROLE_DOCKER")));
}

@Override
public SysUser findByUsername(String userName) {
return users.stream().filter(o -> StringUtils.equals(o.getUserName(), userName)).findFirst().orElse(null);
}
}

UserDetailsService

@Service
public class UserService implements UserDetailsService {

@Autowired
private IUserService iUserService;

@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
SysUser user = iUserService.findByUsername(s);
if (user == null) {
throw new UsernameNotFoundException("用户不存在");
}
//把角色放入认证器里
Collection<GrantedAuthority> authorities = new ArrayList<>();
List<String> roles = user.getRoles();
for (String role : roles) {
authorities.add(new SimpleGrantedAuthority(role));
}
return new User(user.getUserName(), user.getPassword(), authorities);
}

}

AuthenticationFilter

首先呢我们要有一个自定义的 AuthenticationFilter 来实现对自定义登陆的拦截,我们模仿源码去写,拿到请求中的 mobile 构建一个没有认证的 SmsCodeAuthenticationToken

/**
* 短信登录的鉴权过滤器,模仿 UsernamePasswordAuthenticationFilter 实现
*/
public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
/**
* form表单中手机号码的字段name
*/
public static final String SPRING_SECURITY_FORM_MOBILE_KEY = "mobile";

private String mobileParameter = SPRING_SECURITY_FORM_MOBILE_KEY;
/**
* 是否仅 POST 方式
*/
private boolean postOnly = true;

public SmsCodeAuthenticationFilter() {
// 短信登录的请求 post 方式的 /sms/login
super(new AntPathRequestMatcher("/sms/login", "POST"));
}

@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}

String mobile = obtainMobile(request);

if (mobile == null) {
mobile = "";
}

mobile = mobile.trim();

SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile);

// Allow subclasses to set the "details" property
setDetails(request, authRequest);

return this.getAuthenticationManager().authenticate(authRequest);
}

protected String obtainMobile(HttpServletRequest request) {
return request.getParameter(mobileParameter);
}

protected void setDetails(HttpServletRequest request, SmsCodeAuthenticationToken authRequest) {
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
}

public String getMobileParameter() {
return mobileParameter;
}

public void setMobileParameter(String mobileParameter) {
Assert.hasText(mobileParameter, "Mobile parameter must not be empty or null");
this.mobileParameter = mobileParameter;
}

public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
}

AuthenticationToken

上面说到了构建一个没有认证的SmsCodeAuthenticationToken 那么我们就来自己写一个

/*
*这一步的作用是为了替换原有系统的 UsernamePasswordAuthenticationToken 用来做验证
*
* 代码都是从UsernamePasswordAuthenticationToken 里粘贴出来的
*
*/
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

/**
* 在 UsernamePasswordAuthenticationToken 中该字段代表登录的用户名,
* 在这里就代表登录的手机号码
*/
private final Object principal;

/**
* 构建一个没有鉴权的 SmsCodeAuthenticationToken
*/
public SmsCodeAuthenticationToken(Object principal) {
super(null);
this.principal = principal;
setAuthenticated(false);
}

/**
* 构建拥有鉴权的 SmsCodeAuthenticationToken
*/
public SmsCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
super.setAuthenticated(true); // must use super, as we override
}



// ~ Methods
// 剩下的方法不用动就行了 就是从 UsernamePasswordAuthenticationToken 里粘贴出来的
// ========================================================================================================

public Object getCredentials() {
return null;
}

public Object getPrincipal() {
return this.principal;
}

public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
if (isAuthenticated) {
throw new IllegalArgumentException(
"Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
}

super.setAuthenticated(false);
}

@Override
public void eraseCredentials() {
super.eraseCredentials();
}
}

AuthenticationProvider

有了AuthenticationFilter AuthenticationToken 都有了,按照上一篇的逻辑走,肯定要有一个AuthenticationProvider 来进行验证吧。那么我们也来写一个

/**
* 短信登陆鉴权 Provider,要求实现 AuthenticationProvider 接口
*/
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
//上下文中的 userDetailsService
private UserDetailsService userDetailsService;

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication;

String mobile = (String) authenticationToken.getPrincipal();

checkSmsCode(mobile);

UserDetails userDetails = userDetailsService.loadUserByUsername(mobile);

// 此时鉴权成功后,应当重新 new 一个拥有鉴权的 authenticationResult 返回
SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(userDetails, userDetails.getAuthorities());

authenticationResult.setDetails(authenticationToken.getDetails());

return authenticationResult;
}

private void checkSmsCode(String mobile) {
HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();

String inputCode = request.getParameter("smsCode");

//这里的验证码我们放session里,这里拿出来跟用户输入的做对比
Map<String, Object> smsCode = (Map<String, Object>) request.getSession().getAttribute("smsCode");
if (smsCode == null) {
throw new BadCredentialsException("未检测到申请验证码");
}

String applyMobile = (String) smsCode.get("mobile");

int code = (int) smsCode.get("code");

if (!applyMobile.equals(mobile)) {
throw new BadCredentialsException("申请的手机号码与登录手机号码不一致");
}
if (code != Integer.parseInt(inputCode)) {
throw new BadCredentialsException("验证码错误");
}
}

@Override
public boolean supports(Class<?> authentication) {
// 判断 authentication 是不是 SmsCodeAuthenticationToken 的子类或子接口
return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
}

public UserDetailsService getUserDetailsService() {
return userDetailsService;
}

public void setUserDetailsService(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
}

SecurityConfigurerAdapter

AuthenticationFilter AuthenticationToken AuthenticationProvider 全部都有了,那么还差啥?

就差跟 security做绑定了怎么做呢? 不慌我们先定义下成功处理器和失败处理器

成功处理器

@Component
@Slf4j
public class CustomAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
log.info("登录成功");
response.setStatus(HttpStatus.OK.value());
ModelMap modelMap = GenerateModelMap.generateMap(HttpStatus.OK.value(), "登录成功");
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(JSON.toJSONString(modelMap));
}

}

失败处理器

@Component
@Slf4j
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
log.info("登录失败!");
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
ModelMap modelMap = GenerateModelMap.generateMap(HttpStatus.INTERNAL_SERVER_ERROR.value(), "验证失败");
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(JSON.toJSONString(modelMap));
}
}

加入到过滤链里

SecurityConfigurerAdapter 顾名思义就是 SecurityConfigurer的适配器,我们只需要吧我们刚才写的 AuthenticationFilter AuthenticationToken AuthenticationProvider 都放进来就可以与security挂上了。

@Component
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
@Autowired //我们自己定义的UserDetailsService
private UserService userService;
@Autowired
private AuthenticationSuccessHandler customAuthenticationSuccessHandler;
@Autowired
private AuthenticationFailureHandler customAuthenticationFailureHandler;


@Override
public void configure(HttpSecurity http) throws Exception {
SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
//设置AuthenticationManager
smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
//设置失败成功处理器
smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler);
smsCodeAuthenticationFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);
//设置UserDetailsService
SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
smsCodeAuthenticationProvider.setUserDetailsService(userService);
//这里说明要把我们自己写的Provider放在过滤链的哪里
http.authenticationProvider(smsCodeAuthenticationProvider)
.addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}

这样只是加入到了security的过滤链里 但是并没有生效,那么怎么配置呢?对就是还要在 WebSecurityConfigurerAdapter 里配置一下。

WebSecurityConfigurerAdapter

要想让 咱们自定义的配置生效,必须在配置中加入 http.apply(config) 才可以。

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

@Autowired
private CustomAuthenticationFailureHandler customAuthenticationFailureHandler;
@Autowired
private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;
@Autowired //注入咱们自己定义的登陆流程
private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig;
@Autowired
private UserService userService;


@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService).passwordEncoder(
new PasswordEncoder() {
@Override
public String encode(CharSequence charSequence) {
return charSequence.toString();
}

@Override
public boolean matches(CharSequence charSequence, String s) {
return s.equals(charSequence.toString());
}
});
}


@Override
protected void configure(HttpSecurity http) throws Exception {
//表单登陆配置
http.formLogin()
.failureHandler(customAuthenticationFailureHandler)
.successHandler(customAuthenticationSuccessHandler)
.loginPage("/login")
.loginProcessingUrl("/authentication/form")
.and();


http.apply(smsCodeAuthenticationSecurityConfig)
.and()
.logout()
.logoutUrl("/logout")
.and()
.authorizeRequests()
// 如果有允许匿名的url,填在下面
.antMatchers("/login", "/sms/**", "/authentication/form").permitAll()
.anyRequest().authenticated();

// 关闭CSRF跨域
http.csrf().disable();
}
}

至此我们已经完成了我们自定义的登陆流程。

本博文是基于springboot2.x 和security 5 如果有什么不对的请在下方留言。