Java教程

Spring Boot 笔记六——用户认证授权之短信验证码登陆

本文主要是介绍Spring Boot 笔记六——用户认证授权之短信验证码登陆,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

上一篇文章写了使用spring security、oauth2、JWT 实现了最常用的帐号密码登陆功能,但是现在的对外的在线系统基本至少有2种登录方式,用的最多的就是短信验证码,此种方式的好处有很多,例如天然的可以知道用户的手机号_,下面我们就来利用自定义spring security的认证方式实现短信验证码登陆功能。

功能逻辑

1.用户通过手机获取短信验证码
2.用户填写验证码,提交登陆
3.系统判断用户的验证码是否正确,正确则登陆成功,失败则提示错误

以上3点就是使用短信验证码登陆的基本流程判断,当然在实际过程中,每一步都需要做全面的判断,例如下发验证码之前要判断手机号是否存在,短信验证码是否短时间已经发送过,检验短信验证码是否过期等等,这里只是为了说明如何自定义一个登陆方式,在业务层面就不详细展开说了,只是做一个最底层的自定义短信验证码登录架构。

数据库

短信验证码的表(sms_code)

短信验证码
生成对应的控制层、服务层、持久层文件,可以参考之前的文章。

自定义验证码登陆

自定义登录的方式其实就是模拟spring security 默认帐号密码登录的整个过程。
一、首先我们得有个登陆的入口,也就自定义一个类似的“loadUserByUsername(username)”方法。因为帐号密码的登陆不能去掉,所以我们得自定义一个接口并继承“UserDetailsService”自带的接口

/**
 * 自定义登录用户服务接口
 *
 * @author huangm
 * @since 2021年9月23日
 */
public interface HnUserDetailsService extends UserDetailsService {

	/**
	 * 手机验证码登录
	 *
	 * @author huangm
	 * @date 2021年9月23日
	 * @param phone
	 *            手机号
	 * @return
	 * @throws UsernameNotFoundException
	 */
	default CurrentLoginUser loadUserByPhone(final String phone) throws UsernameNotFoundException {
		return null;
	}
}

接口中的方法 loadUserByPhone(String phone)就是短信验证码登录的入口方法。

二、模拟登陆的入口路径
前端提交登陆的路径我们默认固定为sms/login,并固定使用POST方式提交。这里就需要定义一个短信验证码登陆的鉴权过滤器。即模拟“UsernamePasswordAuthenticationFilter”

/**
 * 手机验证码登录的鉴权过滤器,模仿 UsernamePasswordAuthenticationFilter 实现
 *
 * @author huangm
 * @since 2021年6月7日
 */
public class SmsCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

	/**
	 * form表单中手机号码的字段name
	 */
	public static final String SPRING_SECURITY_FORM_MOBILE_KEY = "phone";

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

	public SmsCodeAuthenticationFilter(final AuthenticationManager authManager,
			final AuthenticationSuccessHandler successHandler,
			final AuthenticationFailureHandler failureHandler,
			final ApplicationEventPublisher eventPublisher) {
		// 短信登录的请求 post 方式的 /sms/login
		super(new AntPathRequestMatcher("/sms/login", "POST"));
		this.setAuthenticationManager(authManager);
		this.setAuthenticationSuccessHandler(successHandler);
		this.setAuthenticationFailureHandler(failureHandler);
		this.setApplicationEventPublisher(eventPublisher);
	}

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

		mobile = mobile.trim();
		System.out.println("mobile 0000****" + mobile);
		SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile);

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

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

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

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

	public String getMobileParameter() {
		return this.mobileParameter;
	}

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

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

三、 自定义放置认证信息的TOKEN

 /**
 * 手机验证码登录 AuthenticationToken,模仿 UsernamePasswordAuthenticationToken 实现 <Br>
 * 放置认证的信息
 *
 * @author huangm
 * @since 2021年9月23日
 */
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {

	private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

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

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

	/**
	 * 构建拥有鉴权的 SmsCodeAuthenticationToken
	 */
	public SmsCodeAuthenticationToken(final Object principal,
			final 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(final 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();
	}
}

四、验证码统一判断(鉴权 Provider)
这个自定义的鉴权 Provider,即判断用户提交过来的验证码是否和数据库中有效的验证码一致,如果一致则表示登陆成功,否则返回错误信息。

/**
 * 手机验证码登录鉴权 Provider,要求实现 AuthenticationProvider 接口
 *
 * @author huangm
 * @since 2021年6月7日
 */
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {

	private HnUserDetailsService userDetailsService;

	@Override
	public Authentication authenticate(final Authentication authentication) throws AuthenticationException {
		final SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication;
		final String phone = (String) authenticationToken.getPrincipal();
		final CurrentLoginUser userDetails = this.userDetailsService.loadUserByPhone(phone);
		// 验证手机验证码是否正确
		this.checkSmsCode(userDetails);
		// 此时鉴权成功后,应当重新 new 一个拥有鉴权的 authenticationResult 返回
		final SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(userDetails,
				userDetails.getAuthorities());
		authenticationResult.setDetails(authenticationToken.getDetails());
		return authenticationResult;
	}

	private void checkSmsCode(final CurrentLoginUser userDetails) {
		final HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes())
				.getRequest();
		final String inputCode = request.getParameter("captcha");
		if (HnStringUtils.isBlank(inputCode)) {
			throw new BadCredentialsException("未检测到验证码");
		}
		final Map<String, Object> smsCodeMap = userDetails.getParamsMap();
		if (smsCodeMap == null) {
			throw new BadCredentialsException("未检测到申请验证码");
		}
		final String smsCode = String.valueOf(smsCodeMap.get("captcha"));
		if (HnStringUtils.isBlankOrNULL(smsCode)) {
			throw new BadCredentialsException("未检测到申请验证码");
		}
		final int code = Integer.valueOf(smsCode);
		if (code != Integer.parseInt(inputCode)) {
			throw new BadCredentialsException("验证码错误");
		}
	}

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

	public UserDetailsService getUserDetailsService() {
		return this.userDetailsService;
	}

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

判断验证码的核心代码就是checkSmsCode(loginUser)方法,这里需要跟loadUserByPhone(phone)实现整合一起分析,后面写到登陆的逻辑时再说这块。

五、登陆成功失败的Handler
登陆成功同帐号密码即可,不用填写,只需要填写登陆失败的即可。即在上一篇文章的 SecurityHandlerConfig.java中添加上短信失败后处理的代码即可。

/**
	 * 短信验证码登陆失败
	 *
	 * @return
	 */
	@Bean
	public AuthenticationFailureHandler smsCodeLoginFailureHandler() {
		return new AuthenticationFailureHandler() {

			@Override
			public void onAuthenticationFailure(
					final HttpServletRequest request,
					final HttpServletResponse response,
					final AuthenticationException exception) throws IOException, ServletException {
				String code = null;
				if (exception instanceof BadCredentialsException) {
					code = "smsCodeError";// 手机验证码错误
				} else {
					code = exception.getMessage();
				}
				System.out.println("******sms fail**** " + code);
				HnResponseUtils.responseJson(response, HttpStatus.OK.value(), new ErrorResponse(code));

			}
		};
	}

修改security配置文件

新增了短信验证码的登陆,那么Security的配置文件肯定要坐相应的处理。
SecurityConfig.java :
1.要修改userDetailsService 为上面添加了验证码登陆的HnUserDetailsService
2.要做验证码登陆成功,失败的拦截器处理

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

	@Autowired
	HnUserDetailsService userDetailsService;
	@Autowired
	private AuthenticationSuccessHandler loginSuccessHandler;
	@Autowired
	private AuthenticationFailureHandler loginFailureHandler;
	@Autowired
	private AuthenticationEntryPoint authenticationEntryPoint;
	@Autowired
	private AuthenticationFailureHandler smsCodeLoginFailureHandler;
	@Autowired
	private LogoutSuccessHandler logoutSuccessHandler;
	@Autowired
	private TokenFilter tokenFilter;
	@Resource
	private ApplicationEventPublisher applicationEventPublisher;
	/**
	 * 登陆页面
	 */
	@Value("${login.loginHTML}")
	private String loginHtml;
	/**
	 * 登陆后台处理页面
	 */
	@Value("${login.loginProcessingUrl}")
	private String loginProcessingUrl;
	/**
	 * 不拦截的页面
	 */
	@Value("${login.permitAllUrl}")
	private String permitAllUrl;
	/**
	 * 密码字段名称,默认password
	 */
	@Value("${login.passwordParameter}")
	private String passwordParameter;

	public String[] getPermitAllUrl() {
		final String str = this.permitAllUrl.replaceAll(" ", "");
		return str.split(",");
	}

	@Bean
	public PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder();
	}

	@Override
	protected void configure(final HttpSecurity http) throws Exception {
		http.csrf().disable();
		if (HnStringUtils.isBlank(this.passwordParameter)) {
			this.passwordParameter = "password";
		}

		// 基于token,所以不需要session
		http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

		http.authorizeRequests().antMatchers(this.getPermitAllUrl()).permitAll().anyRequest().authenticated();
		http.formLogin().loginPage(this.loginHtml).loginProcessingUrl(this.loginProcessingUrl)
				.passwordParameter(this.passwordParameter).successHandler(this.loginSuccessHandler)
				.failureHandler(this.loginFailureHandler).and().exceptionHandling()
				.authenticationEntryPoint(this.authenticationEntryPoint).and().logout().logoutUrl("/logout")
				.logoutSuccessHandler(this.logoutSuccessHandler);
		// 解决不允许显示在iframe的问题
		// http.headers().frameOptions().disable();
		// http.headers().cacheControl();

		http.addFilterBefore(this.tokenFilter, UsernamePasswordAuthenticationFilter.class);

		http.addFilterBefore(
				new SmsCodeAuthenticationFilter(this.authenticationManager(), this.loginSuccessHandler,
						this.smsCodeLoginFailureHandler, this.applicationEventPublisher),
				UsernamePasswordAuthenticationFilter.class);
	}

	@Override
	protected void configure(final AuthenticationManagerBuilder auth) throws Exception {		auth.userDetailsService(this.userDetailsService).passwordEncoder(this.passwordEncoder());
		// 自定义 短信登录身份认证组件
		final SmsCodeAuthenticationProvider smsProvider = new SmsCodeAuthenticationProvider();
		smsProvider.setUserDetailsService(this.userDetailsService);
		auth.authenticationProvider(smsProvider);		
	}
}

模拟生成短信验证码

我这里就简单模拟下短信验证码的生成,就不做下发验证码的功能了。

@RestController
@RequestMapping("sms")
public class SysSmsCodeController {

	@Autowired
	ISysSmsCodeService sysSmsCodeService;

	@PostMapping("createCode")
	public AjaxResponse createCode(final String phone, final String codeType) {
		final String captcha = String.valueOf((int) Math.ceil(Math.random() * 9000 + 1000));
		final Date date = new Date();
		final String expireTime = HnDateUtils.add(date, Calendar.MINUTE, 3, "yyyy-MM-dd HH:mm:ss", Locale.CHINA);
		// if (true) {
		// throw new HnException("phoneNonExist");
		// }
		final SysSmsCode smsCode = new SysSmsCode();
		smsCode.setId(HnIdUtils.getNewId());
		smsCode.setPhone(phone);
		smsCode.setCodeType(codeType);//验证码的类型,如登陆,找回密码等
		smsCode.setCaptcha(captcha);//验证码
		smsCode.setExpireTime(expireTime);//3分钟有效期
		smsCode.setCreateDateTime(HnDateUtils.format(date, "yyyy-MM-dd HH:mm:ss"));
		smsCode.setCreateTimeMillis(System.currentTimeMillis());
		this.sysSmsCodeService.save(smsCode);
		System.out.println(HnStringUtils.formatString("{0}:为 {1} 设置短信验证码:{2}", smsCode.getId(), phone, captcha));
		return new SucceedResponse("成功生成验证码!");
	}
}

运行后结果
在这里插入图片描述
数据表中如下
在这里插入图片描述
为此手机号生成了一个有效验证码,有效期3分钟。

验证码登陆逻辑实现

UserDetailsServiceImpl 类修改成实现上面的自定义接口HnUserDetailsService,然后新增验证码登陆方法,

@Override
	public CurrentLoginUser loadUserByPhone(final String phone) throws UsernameNotFoundException {	
		final QueryWrapper<SysUser> wrapper = new QueryWrapper<SysUser>();
		wrapper.eq("phone", phone);
		final SysUser user = this.sysUserService.getOne(wrapper);
		if (user == null) {
			// "手机号不存在"
			throw new UsernameNotFoundException("phoneNonExist");
		}
		// 获取当前手机号的当前有效验证码,用于框架验证,写入到当前用户的paramsMap
		final String datetimeStr = HnDateUtils.format(new Date(), "yyyy-MM-dd HH:mm:ss");

		final QueryWrapper<SysSmsCode> smsCodeWrapper = new QueryWrapper<SysSmsCode>();
		smsCodeWrapper.eq("phone", phone);
		smsCodeWrapper.eq("code_type", "login");
		smsCodeWrapper.ge("expire_time", datetimeStr);
		SysSmsCode sysSmsCode = null;
		final CurrentLoginUser loginUser = this.checkUser(user);
		try {
			sysSmsCode = sysSmsCodeService.getOne(smsCodeWrapper);
			loginUser.addAttribute("captcha", sysSmsCode.getCaptcha());// 将当前有效短信验证码写入
		} catch (final Exception e) {
			// 验证码已经过期
			throw new UsernameNotFoundException("codeExpireTime");
		}
		return loginUser;
	}

注意:这里的loginUser.addAttribute(“captcha”, sysSmsCode.getCaptcha());即将当前数据库中的有效验证码写入登陆用户对象的paramsMap中,而上面的
验证码鉴权 Provider 中的方法checkSmsCode()中的final Map<String, Object> smsCodeMap = userDetails.getParamsMap(); 这句话获取的就是这里写入的验证码,然后与前端用户提交过来的验证码作比较即可判断是否一致。
效果如下图:验证码失效
在这里插入图片描述
手机号不存在:
在这里插入图片描述
验证码错误:
在这里插入图片描述
登录成功:
在这里插入图片描述
成功的效果跟账号密码登陆成功是一样的,怎样就可以实现两种方式的登陆效果了。

PS:生成短信验证码,登陆短信验证码都需要去掉权限判断,所以要将此路径添加到配置文件yml中
在这里插入图片描述

这篇关于Spring Boot 笔记六——用户认证授权之短信验证码登陆的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!