程序员L札记
2022/10/25阅读:26主题:橙心
跟我一起学Spring Security(二)
前言
由于工作原因,让关注这个系列的朋友们久等了,今天我们来实现另一种登录认证方式--手机短信验证码登录,学完这一篇,希望可以举一反三,实现更多其他登录方式。好了,废话不多说,直接开始。
开发短信验证码登录
开发短信验证码接口
@GetMapping("/code/sms")
public void createSmsCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
ValidateCode validateCode = generateSmsCode();
save(validateCode, new ServletWebRequest(request, response));
send(validateCode, new ServletWebRequest(request, response));
}
private ValidateCode generateSmsCode() {
//生成6位的验证码
String code = RandomStringUtils.randomNumeric(6);
//默认过期时间60秒
return new ValidateCode(code, 60);
}
保存方法与前一篇的方法一样,同样是保存到session中,send方法调用具体的短信提供商接口即可,这里就不做实现了。可以看到短信验证码的生成逻辑与图片验证码几乎一样,所以此处可以抽象出来,比如通过模版方法和策略模式模式将验证码生成、保存、发送的三个步骤进行改造,其中的三个步骤又可以进一步进行抽象,比如保存,可以提供保存到session、redis等实现。
登录页面
<form action="/authentication/mobile" method="post">
<table>
<tr>
<td>手机号:</td>
<td><input type="text" name="mobile" value="13012345678"></td>
</tr>
<tr>
<td>短信验证码:</td>
<td>
<input type="text" name="smsCode">
<a href="/code/sms?mobile=13012345678">发送验证码</a>
</td>
</tr>
<tr>
<td colspan='2'><input name="remember-me" type="checkbox" value="true" />记住我</td>
</tr>
<tr>
<td colspan="2"><button type="submit">登录</button></td>
</tr>
</table>
</form>
校验短信验证码并登录
实现原理

开始之前先来简单介绍一下用户名密码登录的原理,以便我们开发短信登录相关功能。首先用户名密码登录会经过UsernamePasswordAuthenticationFilter过滤器,过滤器内部会创建一个未经过认证的UsernamePasswordAuthenticationToken对象,接着会将token传递给AuthenticationManager(认证管理器),AuthenticationManager内部会查找AuthenticationProvider的所有实现类,然后循环检查当前实现类是否支持UsernamePasswordAuthenticationToken类型的token,如果支持就会调用当前的provider,然后AuthenticationProvider会调用我们自定义实现的UserDetailsService,如果根据用户名查找到相关UserDetails信息,那么就会使用未认证的UsernamePasswordAuthenticationToken对象以及UserDetails对象,重新创建一个已认证的Authentication对象并返回。
如果清楚了以上用户名密码的认证原理,那么我们就可以照猫画虎,实现一个手机号短信认证方式。步骤如下:
-
实现一个SmsCodeAuthenticationToken -
实现SmsCodeAuthenticationFilter,内部创建一个未认证的SmsCodeAuthenticationToken,并交给AuthenticationManager -
实现一个SmsCodeAuthenticationProvider,仅支持SmsCodeAuthenticationToken类型的token
SmsCodeAuthenticationToken
public class SmsCodeAuthenticationToken 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 SmsCodeAuthenticationToken(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 SmsCodeAuthenticationToken(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;
}
@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();
}
}
代码来源于UsernamePasswordAuthenticationToken,删减掉不相干的属性和代码,请自行做对比。
SmsCodeAuthenticationFilter
public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
// ~ Static fields/initializers
// =====================================================================================
private String mobileParameter = "mobile";
private boolean postOnly = true;
// ~ Constructors
// ===================================================================================================
public SmsCodeAuthenticationFilter() {
super(new AntPathRequestMatcher("/authentication/mobile", "POST"));
}
// ~ Methods
// ========================================================================================================
@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);
}
/**
* 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, SmsCodeAuthenticationToken authRequest) {
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
}
/**
* Sets the parameter name which will be used to obtain the username from
* the login request.
*
* @param usernameParameter
* the parameter name. Defaults to "username".
*/
public void setMobileParameter(String usernameParameter) {
Assert.hasText(usernameParameter, "Username parameter must not be empty or null");
this.mobileParameter = usernameParameter;
}
/**
* 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;
}
}
代码来源于UsernamePasswordAuthenticationFilter,删减掉了一些不想干的代码,请自行做对比。
SmsCodeAuthenticationProvider
/**
* 短信登录验证逻辑
*
* 由于短信验证码的验证在过滤器里已完成,这里直接读取用户信息即可。
*
*/
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {
private UserDetailsService userDetailsService;
/*
* (non-Javadoc)
*
* @see org.springframework.security.authentication.AuthenticationProvider#
* authenticate(org.springframework.security.core.Authentication)
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication;
UserDetails user = userDetailsService.loadUserByUsername((String) authenticationToken.getPrincipal());
if (user == null) {
throw new InternalAuthenticationServiceException("无法获取用户信息");
}
SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(user, user.getAuthorities());
authenticationResult.setDetails(authenticationToken.getDetails());
return authenticationResult;
}
/*
* (non-Javadoc)
*
* @see org.springframework.security.authentication.AuthenticationProvider#
* supports(java.lang.Class)
*/
@Override
public boolean supports(Class<?> authentication) {
return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication);
}
public UserDetailsService getUserDetailsService() {
return userDetailsService;
}
public void setUserDetailsService(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
}
配置
@Configurable
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;
@Autowired
private CustomAuthenticationFailureHandler customAuthenticationFailureHandler;
@Autowired
private DataSource dataSource;
@Autowired
private UserDetailsService userDetailsService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
//验证码校验(内部增加短信验证码的校验逻辑,自行实现)
ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter();
validateCodeFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);
//短信登录
SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler);
smsCodeAuthenticationFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);
SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
smsCodeAuthenticationProvider.setUserDetailsService(userDetailsService);
http.authenticationProvider(smsCodeAuthenticationProvider)
.addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class)
.formLogin(formLogin -> formLogin
.loginPage("/login.html")//自定义登录页面
.loginProcessingUrl("/doLogin")//自定义登录请求的url,默认/login
.successHandler(customAuthenticationSuccessHandler)//自定义登录成功后的处理
.failureHandler(customAuthenticationFailureHandler)//自定义登录失败后的处理
)//开启表单认证
.rememberMe()
.tokenRepository(persistentTokenRepository())
.tokenValiditySeconds(3600)
.userDetailsService(userDetailsService)
.and()
.authorizeRequests(authorize -> authorize
.antMatchers("/login.html", "/favicon.ico", "/code/image", "/authentication/mobile").permitAll()//不需要经过身份认证的资源
.anyRequest().authenticated()//其他任何请求都需要经过身份认证
)//授权请求
.csrf().disable();
return http.build();
}
/**
* 记住我功能的token存取器配置
*
* @return
*/
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
//启动时创建数据库表(下次启动改为false)
tokenRepository.setCreateTableOnStartup(true);
return tokenRepository;
}
}
总结
至此已经实现了手机短信认证方式。由于时间有限,可能会存在遗漏的地方,后期会慢慢补充并做更新,请持续关注。
大家晚安!
欢迎关注我的公众号:程序员L札记

作者介绍