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);
}
其實類似的郵箱驗證登錄也大概可以使用這個流程。