1. 短信验证码的生成
首先自定义一个短信验证码类
package com.blog.security.smscode;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* blog
*
* @author : xgj
* @description : 短信验证码类
* @date : 2020-08-20 07:36
**/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class SmsCode {
private String phoneNumber;
private String code;
private LocalDateTime expireTime;
public boolean isExpired() {
return LocalDateTime.now().isAfter(expireTime);
}
}
接着在Controller中加入生成短信验证码相关请求对应的方法:
@GetMapping("/smscode")
@ResponseBody
public Map<String,String> sms(@RequestParam String mobile, HttpSession session) {
Map<String,String> map = new HashMap<>(16);
if(userService.queryByPhoneNumber(mobile) == null){
map.put("jsonmsg","手机号未注册");
}else{
//这里我偷懒了,具体实现可以调用短信验证提供商的api
SmsCode smsCode = new SmsCode(mobile,RandomStringUtils.randomNumeric(6),LocalDateTime.now().plusSeconds(60));
session.setAttribute("sms_key",smsCode);
System.out.println(smsCode.getCode());
map.put("jsonmsg","发送验证码成功");
}
return map;
}
然后搞定登陆界面
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:replace="_fragments :: head(~{::title})">
<title>博客管理登录</title>
</head>
<body class="login-bg">
<br>
<br>
<br>
<div class="m-container-small m-padded-tb-massive disabled" style="max-width: 30em !important;">
<div class="ur container">
<div class="ui middle aligned center aligned grid">
<div class="column">
<h2 class="ui teal image header">
<div class="content">
手机号码登录
</div>
</h2>
<form class="ui large form" method="post" action="#" th:action="@{/sms/login}">
<div class="ui segment">
<div class="field">
<div class="ui left icon input">
<i class="user icon"></i>
<input type="text" id="mobile" name="mobile" placeholder="手机号码">
</div>
</div>
<div class="field">
<div class="ui left icon input">
<i class="lock icon"></i>
<input type="text" name="smsCode" placeholder="验证码">
</div>
</div>
<button class="ui fluid large teal submit button">登 录</button>
</div>
<div class="ui mini negative message" th:text="${msg}" th:if="${not #strings.isEmpty(msg)}"></div>
</form>
<button class="ui pink header item" onclick="getSmsCode()">获取验证码</button>
</div>
</div>
</div>
</div>
<!--/*/<th:block th:replace="_fragments :: script">/*/-->
<script src="https://cdn.jsdelivr.net/npm/jquery@3.2/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/semantic-ui/2.2.4/semantic.min.js"></script>
<!--/*/</th:block>/*/-->
<script>
function getSmsCode() {
$.ajax({
type: "get",
url: "/smscode",
data: {
"mobile": $("#mobile").val()
},
success: function (json) {
if (json.isok) {
alert(json.data)
} else {
alert(json.jsonmsg)
}
},
error: function (e) {
console.log(e.responseText);
}
});
}
</script>
</body>
</html>
2.实现短信验证流程
在这个流程中,我们自定义了一个名为SmsCodeAuthenticationFilter的过滤器来拦截短信验证码登录请求,并将手机号码封装到一个叫SmsCodeAuthenticationToken的对象中。在Spring Security中,认证处理都需要通过AuthenticationManager来代理,所以这里我们依旧将SmsCodeAuthenticationToken交由AuthenticationManager处理。接着我们需要定义一个支持处理SmsCodeAuthenticationToken对象的SmsCodeAuthenticationProvider,SmsCodeAuthenticationProvider调用UserDetailService的loadUserByUsername方法来处理认证。与用户名密码认证不一样的是,这里是通过SmsCodeAuthenticationToken中的手机号去数据库中查询是否有与之对应的用户,如果有,则将该用户信息封装到UserDetails对象中返回并将认证后的信息保存到Authentication对象中。
为了实现这个流程,我们需要定义SmsCodeAuthenticationFilter、SmsCodeAuthenticationToken、SmsCodeAuthenticationProvider和SmsCodeUserDetailsService,并将这些组建组合起来添加到Spring Security中。下面我们来逐步实现这个过程。
然后在整个过滤链前添加SmsCodeValidateFilter进行验证码验证。
- SmsCodeValidateFilter
package com.blog.security.smscode;
import com.blog.pojo.User;
import com.blog.security.PublicAuthenticationFailureHandler;
import com.blog.service.UserService;
import com.blog.util.MyContants;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.session.SessionAuthenticationException;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.Objects;
/**
* blog
*
* @author : xgj
* @description : 前置手机验证登录
* @date : 2020-08-20 12:29
**/
@Component
public class SmsCodeValidateFilter extends OncePerRequestFilter {
@Autowired
private UserService userService;
@Autowired
private PublicAuthenticationFailureHandler publicAuthenticationFailureHandler;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws IOException, ServletException {
if (StringUtils.equals(MyContants.SMS_FILTER_URL, request.getRequestURI())
&& StringUtils.equalsIgnoreCase(request.getMethod(), MyContants.REQUEST_MAPPING_POST)) {
try {
//验证谜底与用户输入是否匹配
validate(new ServletWebRequest(request));
} catch (AuthenticationException e) {
publicAuthenticationFailureHandler.onAuthenticationFailure(request, response, e);
return;
}
}
filterChain.doFilter(request, response);
}
private void validate(ServletWebRequest request) throws SessionAuthenticationException {
Logger logger = LoggerFactory.getLogger(getClass());
HttpSession session = request.getRequest().getSession();
SmsCode codeInSession = (SmsCode) session.getAttribute("sms_key");
String mobileInRequest = request.getParameter("mobile");
String codeInRequest = request.getParameter("smsCode");
logger.info("SmsCodeValidateFilter--->"+codeInSession.toString()+mobileInRequest+codeInRequest);
if (StringUtils.isEmpty(mobileInRequest)) {
throw new SessionAuthenticationException("手机号码不能为空");
}
if (StringUtils.isEmpty(codeInRequest)) {
throw new SessionAuthenticationException("短信验证码不能为空");
}
if (Objects.isNull(codeInSession)) {
throw new SessionAuthenticationException("短信验证码不存在");
}
if (codeInSession.isExpired()) {
session.removeAttribute("sms_key");
throw new SessionAuthenticationException("短信验证码已经过期");
}
if (!codeInSession.getCode().equals(codeInRequest)) {
throw new SessionAuthenticationException("短信验证码不正确");
}
if (!codeInSession.getPhoneNumber().equals(mobileInRequest)) {
throw new SessionAuthenticationException("短信发送目标与您输入的手机号不一致");
}
User user = userService.queryByPhoneNumber(mobileInRequest);
if (Objects.isNull(user)) {
throw new SessionAuthenticationException("您输入的手机号不是系统的注册用户");
}
session.removeAttribute("sms_key");
}
}
- SmsCodeAuthenticationFilter
package com.blog.security.smscode;
import com.blog.util.MyContants;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.util.Assert;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 短信登录的鉴权过滤器,模仿 UsernamePasswordAuthenticationFilter 实现
*
* @author xgj
* @date : 2020-08-20 12:29
*/
public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private String mobileParameter = MyContants.SMS_FORM_MOBILE_KEY;
/**
* 是否仅 POST 方式
*/
private boolean postOnly = true;
public SmsCodeAuthenticationFilter() {
// 短信登录的请求 post 方式的 /sms/login
super(new AntPathRequestMatcher(MyContants.SMS_FILTER_URL, MyContants.REQUEST_MAPPING_POST));
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
if (postOnly && !MyContants.REQUEST_MAPPING_POST.equals(request.getMethod())) {
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);
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;
}
}
- SmsCodeAuthenticationProvider
package com.blog.security.smscode;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
/**
* 短信登陆鉴权 Provider,要求实现 AuthenticationProvider 接口
*
* @author xgj
* @date : 2020-08-20 12:29
*/
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
private UserDetailsService userDetailsService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication;
String mobile = (String) authenticationToken.getPrincipal();
UserDetails userDetails = userDetailsService.loadUserByUsername(mobile);
if(userDetails == null){
throw new InternalAuthenticationServiceException("无法根据手机号获取用户信息");
}
// 此时鉴权成功后,应当重新 new 一个拥有鉴权的 authenticationResult 返回
SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(userDetails, userDetails.getAuthorities());
authenticationResult.setDetails(authenticationToken.getDetails());
return authenticationResult;
}
@Override
public boolean supports(Class<?> authentication) {
// 判断 authentication 是不是 SmsCodeAuthenticationToken 的子类或子接口
return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
}
public UserDetailsService getUserDetailService() {
return userDetailsService;
}
public void setUserDetailService(UserDetailsService userDetailService) {
this.userDetailsService = userDetailService;
}
}
- SmsCodeAuthenticationToken
package com.blog.security.smscode;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.SpringSecurityCoreVersion;
import java.util.Collection;
/**
* 短信登录 AuthenticationToken,模仿 UsernamePasswordAuthenticationToken 实现
*
* @author xgj
* @since 2019/1/9 13:47
*/
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;
// must use super, as we override
super.setAuthenticated(true);
}
@Override
public Object getCredentials() {
return null;
}
@Override
public Object getPrincipal() {
return this.principal;
}
@Override
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();
}
}
- SmsCodeUserDetailsService
package com.blog.security.service;
import com.blog.pojo.Role;
import com.blog.pojo.User;
import com.blog.pojo.UserRole;
import com.blog.service.RoleService;
import com.blog.service.UserRoleService;
import com.blog.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/**
* blog
*
* @author : xgj
* @description : 基于短信验证的userdetails
* @date : 2020-08-17 09:24
**/
@Service("smsCodeUserDetailsService")
public class SmsCodeUserDetailsService implements UserDetailsService {
@Autowired
private UserService userService;
@Autowired
private RoleService roleService;
@Autowired
private UserRoleService userRoleService;
@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
Collection<GrantedAuthority> authorities = new ArrayList<>();
// 从数据库中取出用户信息
User user = userService.queryByPhoneNumber(s);
// 判断用户是否存在
if (user == null) {
throw new UsernameNotFoundException("用户名不存在");
}
// 添加权限
List<UserRole> userRoles = userRoleService.listByUserId(user.getId());
for (UserRole userRole : userRoles) {
Role role = roleService.selectById(userRole.getRoleId());
authorities.add(new SimpleGrantedAuthority(role.getName()));
}
// 返回UserDetails实现类
return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), authorities);
}
}
然后编写配置类
package com.blog.config;
import com.blog.security.PublicAuthenticationFailureHandler;
import com.blog.security.PublicAuthenticationSuccessHandler;
import com.blog.security.service.CustomUserDetailsService;
import com.blog.security.service.SmsCodeUserDetailsService;
import com.blog.security.smscode.SmsCodeAuthenticationFilter;
import com.blog.security.smscode.SmsCodeAuthenticationProvider;
import com.blog.security.smscode.SmsCodeValidateFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.stereotype.Component;
/**
* @author xgj
* 手机短信验证
*/
@Component("smsCodeAuthenticationSecurityConfig")
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
@Autowired
private AuthenticationSuccessHandler authenticationSuccessHandler;
@Autowired
private AuthenticationFailureHandler authenticationFailureHandler;
@Autowired
@Qualifier("smsCodeUserDetailsService")
private SmsCodeUserDetailsService smsCodeUserDetailsService;
@Override
public void configure(HttpSecurity http) {
SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
smsCodeAuthenticationFilter.setAuthenticationFailureHandler(authenticationFailureHandler);
SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
smsCodeAuthenticationProvider.setUserDetailService(smsCodeUserDetailsService);
http.authenticationProvider(smsCodeAuthenticationProvider)
.addFilterAfter(smsCodeAuthenticationFilter,UsernamePasswordAuthenticationFilter.class);
}
}
最后配置生效
@Override
protected void configure(HttpSecurity http) throws Exception {
// 添加短信验证码校验过滤器
http.addFilterBefore(smsCodeValidateFilter, UsernamePasswordAuthenticationFilter.class)
...
.apply(smsCodeAuthenticationSecurityConfig);
}
其实类似的邮箱验证登录也大概可以使用这个流程。