Spring Security -- 短信验证码登录(转载)
目录
在Spring Security -- 添加图形验证码一节中,我们已经实现了基于Spring Boot + Spring Security的账号密码登录,并集成了图形验证码功能。当前另一种非常常见的网站登录方式为手机短信验证码登录,但Spring Security默认只提供了账号密码的登录认证逻辑,所以要实现手机短信验证码登录认证功能,我们需要模仿Spring Security账号密码登录逻辑代码来实现一套自己的认证逻辑。
一、短信验证码生成
我们在Spring Security -- 添加图形验证码的基础上来集成短信验证码登录的功能。
1、SmsCode实体类
和图形验证码类似,我们先定义一个短信验证码对象SmsCode:
package com.zy.example.entity;
import lombok.Data;
import java.time.LocalDateTime;
/**
* @Author: zy
* @Description: 手机验证码实体类
* @Date: 2020-2-9
*/
@Data
public class SmsCode {
/**
* code验证码
*/
private String code;
/**
* 过期时间 单位秒
*/
private LocalDateTime expireTime;
/**
* 判断验证码是否过期
* @return
*/
public boolean isExpire() {
return LocalDateTime.now().isAfter(expireTime);
}
/**
* 构造函数
* @param code
* @param expireIn
*/
public SmsCode(String code, int expireIn) {
this.code = code;
this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
}
/**
* 构造函数
* @param code
* @param expireTime
*/
public SmsCode(String code, LocalDateTime expireTime) {
this.code = code;
this.expireTime = expireTime;
}
}
SmsCode对象包含了两个属性:code验证码和expireTime过期时间。isExpire方法用于判断短信验证码是否已过期。
2、ValidateCodeController
接着在ValidateCodeController中加入生成短信验证码相关请求对应的方法:
/**
* 手机验证码
*/
public final static String SESSION_KEY_SMS_CODE = "SESSION_KEY_SMS_CODE";
/**
* 用于生成手机验证码
* @param request:请求
* @param response:响应
* @param mobile:手机号码
* @throws IOException:异常
*/
@RequestMapping("/sms")
public void createSmsCode(HttpServletRequest request, HttpServletResponse response,@RequestParam String mobile) throws IOException {
//生成手机验证码对象
SmsCode smsCode = createSMSCode();
//生成的验证码对象存储到Session中
sessionStrategy.setAttribute(new ServletWebRequest(request), SESSION_KEY_SMS_CODE + mobile, smsCode);
// 输出验证码到控制台代替短信发送服务
System.out.println("您的登录验证码为:" + smsCode.getCode() + ",有效时间为60秒");
}
/**
* 用于生成手机验证码对象
* @return
*/
private SmsCode createSMSCode() {
String code = RandomStringUtils.randomNumeric(6);
return new SmsCode(code, 60);
}
这里我们使用createSMSCode方法生成了一个6位的纯数字随机数,有效时间为60秒。然后通过SessionStrategy对象的setAttribute方法将短信验证码保存到了Session中,对应的key为SESSION_KEY_SMS_CODE。
至此,短信验证码生成模块编写完毕,下面开始改造登录页面。
二、登录页
我们在登录页面中加入一个与手机短信验证码认证相关的Form表单:
<div id="content">
<div id="box">
<div class="title">短信验证码登录</div>
<div class="input">
<form name="f" action="/login/mobile" method="post">
<input type="text" placeholder="手机号" name="mobile" value="17777777777" required="required"/>
<br>
<input type="text" name="smsCode" placeholder="短信验证码" style="width: 50%;"/>
<a href="/code/sms?mobile=17777777777" target="_blank">发送验证码</a>
<br>
<input type="submit" value="登录" />
</form>
</div>
</div>
</div>
其中a标签的href属性值对应我们的短信验证码生成方法的请求URL。同时,我们需要在Spring Security中配置/code/sms路径免验证:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class) // 添加验证码校验过滤器
.authorizeRequests() // 授权配置
.antMatchers("/code/image","/code/sms")
.permitAll() // 无需认证的请求路径
.anyRequest() // 任何请求
.authenticated() //都需要身份认证
.and()
.formLogin() // 或者httpBasic()
.loginPage("/login") // 指定登录页的路径
.loginProcessingUrl("/login") // 指定自定义form表单请求的路径
.successHandler(authenticationSucessHandler) // 处理登录成功
.failureHandler(authenticationFailureHandler) // 处理登录失败
// 必须允许所有用户访问我们的登录页(例如未验证的用户,否则验证流程就会进入死循环)
// 这个formLogin().permitAll()方法允许所有用户基于表单登录访问/login这个page。
.permitAll()
.and()
.rememberMe()
.tokenRepository(persistentTokenRepository) // 配置 token 持久化仓库
.tokenValiditySeconds(3600) // remember 过期时间,单为秒
.userDetailsService(userDetailService) // 处理自动登录逻辑
.and()
.logout()
.permitAll()
.and()
//默认都会产生一个hiden标签 里面有安全相关的验证 防止请求伪造 这边我们暂时不需要 可禁用掉
.csrf().disable();
}
这里有一点需要注意,我们的form表单action是将请求提交到/login/mobile页面,而在Spring Security中配置的 .loginProcessingUrl("/login") 值为/login,这两者为什么不一样呢?这样做的目的是通过指定Spring Security中的UsernamePasswordAuthenticationFilter的拦截目标为post请求/login,从而使得该过滤器不会拦截/login/mobile请求;那么针对/login/mobile请求我们会仿照UsernamePasswordAuthenticationFilter定义自己的过滤器,然后对其进行认证;
重启项目,访问http://localhost:8080/login:
点击发送验证码,控制台输出如下:
您的登录验证码为:788974,有效时间为60秒
接下来开始实现使用短信验证码登录认证逻辑。
三、添加短信验证码认证
1、实现原理
在Spring Security中,使用用户名密码认证的过程大致如下图所示:
- Spring Security使用UsernamePasswordAuthenticationFilter过滤器来拦截用户名密码认证请求(默认只有当请求方法为post、请求页面为/login时过滤器才生效,但是可以通过.loginProcessingUrl("/url")修改拦截路径 ),将用户名和密码封装成一个UsernamePasswordAuthenticationToken对象交给AuthenticationManager处理;
- AuthenticationManager将挑出一个支持处理UsernamePasswordAuthenticationToken对象的的AuthenticationProvider(这里为DaoAuthenticationProvider,AuthenticationProvider的其中一个实现类)来进行认证;
- 认证过程中DaoAuthenticationProvider将调用UserDetailService的loadUserByUsername方法来处理认证,如果认证通过(即UsernamePasswordAuthenticationToken中的用户名和密码相符)则返回一个UserDetails类型对象,并将认证信息保存到Session中,认证后我们便可以通过Authentication对象获取到认证的信息了;
我们这里来具体介绍一下DaoAuthenticationProvider是如何进行密码认证?
其主要包含两个用户数据获取和一个比较:
- 第一个用户数据获取:从浏览器输入的用户和密码,用户名和密码会传递到Spring Security内部,最终封装成UsernamePasswordAuthenticationToken对象authentication;
- 第二个用户数据获取:利用loadUserByUsername函数根据用户名从数据库获得用户详情;
- 一个比较:将前面两个数据获取进行比较,匹配则通过验证,否则验证不通过;
DaoAuthenticationProvider其具体认证方法为authenticate,定义在父抽象类AbstractUserDetailsAuthenticationProvider中:
public Authentication authenticate(Authentication authentication) // 重点关注这个函数,不重要先略去
throws AuthenticationException {
......
try {
preAuthenticationChecks.check(user);
// 重点看这个地方,additionalAuthenticationChecks这个函数会被DaoAuthenticationProvider实现。所以我们要看看DaoAuthenticationProvider的additionalAuthenticationChecks函数实现
additionalAuthenticationChecks(user,(UsernamePasswordAuthenticationToken) authentication);
}
catch (AuthenticationException exception) {
if (cacheWasUsed) {
// There was a problem, so try again after checking
// we're using latest data (i.e. not from the cache)
cacheWasUsed = false;
user = retrieveUser(username,
(UsernamePasswordAuthenticationToken) authentication);
preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user,
(UsernamePasswordAuthenticationToken) authentication);
}
else {
throw exception;
}
}
postAuthenticationChecks.check(user);
......
return createSuccessAuthentication(principalToReturn, authentication, user);
}
其中user的是通过调用loadUserByUsername获取的UserDetails对象,而authentication就是表单用户名和密码封装后的UsernamePasswordAuthenticationToken对象;
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
if (authentication.getCredentials() == null) {
logger.debug("Authentication failed: no credentials provided");
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
String presentedPassword = authentication.getCredentials().toString();
// 密码比较就在这个地方,前面这个是用户输入的密码,后面这个是数据库存的密码,一致则通过
if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
logger.debug("Authentication failed: password does not match stored value");
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
}
}
通过这两步分析,应该很清楚密码的比较了吧。
注意:上图仅仅是用户名和密码的认证流程,并不包括授权流程;
由于Spring Security并没用提供短信验证码认证的流程,所以我们需要仿照上面这个流程来实现:
- 在这个流程中,我们自定义了一个名为SmsAuthenticationFitler的过滤器来拦截短信验证码登录请求(只有当请求方法为post、请求页面为/login/mobile时过滤器才生效),并将手机号码封装到一个叫SmsAuthenticationToken的对象中,在Spring Security中,认证处理都需要通过AuthenticationManager来代理,所以这里我们依旧将SmsAuthenticationToken交由AuthenticationManager处理;
- 接着我们需要定义一个支持处理SmsAuthenticationToken对象的SmsAuthenticationProvider;
- SmsAuthenticationProvider调用UserDetailService的loadUserByUsername方法来处理认证,与用户名密码认证不一样的是,这里是通过SmsAuthenticationToken中的手机号去数据库中查询是否有与之对应的用户,如果有,则将该用户信息封装到UserDetails对象中返回,并将认证后的信息保存到Authentication对象中;
为了实现这个流程,我们需要定义SmsAuthenticationFitler、SmsAuthenticationToken和SmsAuthenticationProvider,并将这些组建组合起来添加到Spring Security中。下面我们来逐步实现这个过程。
2、定义SmsAuthenticationToken
查看UsernamePasswordAuthenticationToken的源码,将其复制出来重命名为SmsAuthenticationToken,并稍作修改,修改后的代码如下所示:
package com.zy.example.entity;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.SpringSecurityCoreVersion;
import java.util.Collection;
/**
* @Author: zy
* @Description: 手机验证token实体
* @Date: 2020-2-9
*/
public class SmsAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
// ~ Instance fields
// ================================================================================================
private final Object principal;
// ~ Constructors
// ===================================================================================================
/**
* This constructor can be safely used by any code that wishes to create a
* <code>UsernamePasswordAuthenticationToken</code>, as the {@link #isAuthenticated()}
* will return <code>false</code>.
*
*/
public SmsAuthenticationToken(String mobile) {
super(null);
this.principal = mobile;
setAuthenticated(false);
}
/**
* This constructor should only be used by <code>AuthenticationManager</code> or
* <code>AuthenticationProvider</code> implementations that are satisfied with
* producing a trusted (i.e. {@link #isAuthenticated()} = <code>true</code>)
* authentication token.
*
* @param principal
* @param authorities
*/
public SmsAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
super.setAuthenticated(true); // must use super, as we override
}
// ~ Methods
// ========================================================================================================
@Override
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();
}
}
SmsAuthenticationToken包含一个principal属性,从它的两个构造函数可以看出,在认证之前principal存的是手机号,认证之后存的是用户信息。UsernamePasswordAuthenticationToken原来还包含一个credentials属性用于存放密码,这里不需要就去掉了。
3、定义SmsAuthenticationFilter
定义完SmsAuthenticationToken后,我们接着定义用于处理短信验证码登录请求的过滤器SmsAuthenticationFilter,同样的复制UsernamePasswordAuthenticationFilter源码并稍作修改:
package com.zy.example.filter; import com.zy.example.entity.SmsAuthenticationToken; import org.springframework.lang.Nullable; 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; /** * @Author: zy * @Description: 用于处理短信验证码登录请求的过滤器 * @Date: 2020-2-9 */public class SmsAuthenticationFilter extends AbstractAuthenticationProcessingFilter { // ~ Static fields/initializers // ===================================================================================== public static final String SPRING_SECURITY_FORM_MOBILE_KEY = "mobile"; private String mobileParameter = SPRING_SECURITY_FORM_MOBILE_KEY; private boolean postOnly = true; // ~ Constructors // =================================================================================================== public SmsAuthenticationFilter() { super(new AntPathRequestMatcher("/login/mobile", "POST")); } // ~ Methods // ======================================================================================================== 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(); SmsAuthenticationToken authRequest = new SmsAuthenticationToken( mobile); // Allow subclasses to set the "details" property setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); } /** * Enables subclasses to override the composition of the username, such as by * including additional values and a separator. * * @param request so that request attributes can be retrieved * * @return the username that will be presented in the <code>Authentication</code> * request token to the <code>AuthenticationManager</code> */ @Nullable protected String obtainMobile(HttpServletRequest request) { return request.getParameter(mobileParameter); } /** * Provided so that subclasses may configure what is put into the authentication * request's details property. * * @param request that an authentication request is being created for * @param authRequest the authentication request object that should have its details * set */ protected void setDetails(HttpServletRequest request, SmsAuthenticationToken authRequest) { authRequest.setDetails(authenticationDetailsSource.buildDetails(request)); } /** * Sets the parameter name which will be used to obtain the username from the login * request. * * @param mobileParameter the parameter name. Defaults to "username". */ public void setMobileParameter(String mobileParameter) { Assert.hasText(mobileParameter, "Username parameter must not be empty or null"); this.mobileParameter = mobileParameter; } /** * Defines whether only HTTP POST requests will be allowed by this filter. If set to * true, and an authentication request is received which is not a POST request, an * exception will be raised immediately and authentication will not be attempted. The * <tt>unsuccessfulAuthentication()</tt> method will be called as if handling a failed * authentication. * <p> * Defaults to <tt>true</tt> but may be overridden by subclasses. */ public void setPostOnly(boolean postOnly) { this.postOnly = postOnly; } public final String getMobileParameter() { return mobileParameter; } }
构造函数中指定了当请求为/login/mobile,请求方法为POST的时候该过滤器生效。mobileParameter属性值为mobile,对应登录页面手机号输入框的name属性。attemptAuthentication方法从请求中获取到mobile参数值,并调用SmsAuthenticationToken的SmsAuthenticationToken(String mobile)构造方法创建了一个SmsAuthenticationToken。下一步就如流程图中所示的那样,SmsAuthenticationFilter将SmsAuthenticationToken交给AuthenticationManager处理。
4、定义SmsAuthenticationProvider
在创建完SmsAuthenticationFilter后,我们需要创建一个支持处理SmsAuthenticationToken的类,即SmsAuthenticationProvider,该类需要实现AuthenticationProvider的两个抽象方法:
package com.zy.example.service;
import com.zy.example.entity.SmsAuthenticationToken;
import lombok.Getter;
import lombok.Setter;
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;
import org.springframework.stereotype.Service;
/**
* @Author: zy
* @Description: 用于处理SmsAuthenticationToken
* @Date: 2020-2-9
*/
@Service
public class SmsAuthenticationProvider implements AuthenticationProvider {
@Getter
@Setter
private UserDetailsService userDetailsService;
/**
* 具体的身份认证逻辑
* @param authentication:SmsAuthenticationToken
* @return
* @throws AuthenticationException
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
SmsAuthenticationToken authenticationToken = (SmsAuthenticationToken) authentication;
//通过手机号去查询用户,如果存在该用户则认证通过,否则认证失败
UserDetails userDetails = userDetailsService.loadUserByUsername((String) authenticationToken.getPrincipal());
if (userDetails == null)
throw new InternalAuthenticationServiceException("未找到与该手机号对应的用户");
//函数构造一个认证通过的Token,包含了用户信息和用户权限
SmsAuthenticationToken authenticationResult = new SmsAuthenticationToken(userDetails, userDetails.getAuthorities());
authenticationResult.setDetails(authenticationToken.getDetails());
return authenticationResult;
}
/**
* 判断authentication是否是SmsAuthenticationToken类型的对象
* @param authentication:token
* @return
*/
@Override
public boolean supports(Class<?> authentication) {
return SmsAuthenticationToken.class.isAssignableFrom(authentication);
}
}
其中supports方法指定了支持处理的Token类型为SmsAuthenticationToken,authenticate方法用于编写具体的身份认证逻辑。
在authenticate方法中,我们从SmsAuthenticationToken中取出了手机号信息,并调用了UserDetailService的loadUserByUsername方法。
亲爱的读者和支持者们,自动博客加入了打赏功能,陆陆续续收到了各位老铁的打赏。在此,我想由衷地感谢每一位对我们博客的支持和打赏。你们的慷慨与支持,是我们前行的动力与源泉。
日期 | 姓名 | 金额 |
---|---|---|
2023-09-06 | *源 | 19 |
2023-09-11 | *朝科 | 88 |
2023-09-21 | *号 | 5 |
2023-09-16 | *真 | 60 |
2023-10-26 | *通 | 9.9 |
2023-11-04 | *慎 | 0.66 |
2023-11-24 | *恩 | 0.01 |
2023-12-30 | I*B | 1 |
2024-01-28 | *兴 | 20 |
2024-02-01 | QYing | 20 |
2024-02-11 | *督 | 6 |
2024-02-18 | 一*x | 1 |
2024-02-20 | c*l | 18.88 |
2024-01-01 | *I | 5 |
2024-04-08 | *程 | 150 |
2024-04-18 | *超 | 20 |
2024-04-26 | .*V | 30 |
2024-05-08 | D*W | 5 |
2024-05-29 | *辉 | 20 |
2024-05-30 | *雄 | 10 |
2024-06-08 | *: | 10 |
2024-06-23 | 小狮子 | 666 |
2024-06-28 | *s | 6.66 |
2024-06-29 | *炼 | 1 |
2024-06-30 | *! | 1 |
2024-07-08 | *方 | 20 |
2024-07-18 | A*1 | 6.66 |
2024-07-31 | *北 | 12 |
2024-08-13 | *基 | 1 |
2024-08-23 | n*s | 2 |
2024-09-02 | *源 | 50 |
2024-09-04 | *J | 2 |
2024-09-06 | *强 | 8.8 |
2024-09-09 | *波 | 1 |
2024-09-10 | *口 | 1 |
2024-09-10 | *波 | 1 |
2024-09-12 | *波 | 10 |
2024-09-18 | *明 | 1.68 |
2024-09-26 | B*h | 10 |
2024-09-30 | 岁 | 10 |
2024-10-02 | M*i | 1 |
2024-10-14 | *朋 | 10 |
2024-10-22 | *海 | 10 |
2024-10-23 | *南 | 10 |
2024-10-26 | *节 | 6.66 |
2024-10-27 | *o | 5 |
2024-10-28 | W*F | 6.66 |
2024-10-29 | R*n | 6.66 |
2024-11-02 | *球 | 6 |
2024-11-021 | *鑫 | 6.66 |
2024-11-25 | *沙 | 5 |
2024-11-29 | C*n | 2.88 |

【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· 没有源码,如何修改代码逻辑?
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了