【Spring Security系列】如何用Spring Security集成手机验证码登录?五分钟搞定!

1. 前言

在当今的互联网应用中,手机验证码登录已经成为一种常见的用户身份验证方式。相比传统的用户名密码登录方式,手机验证码具有使用方便、安全性较高的特点。对于开发者来说,如何在现有的系统中快速集成这一功能,尤其是在Spring Security框架下,可能是一个具有挑战性的任务。这篇文章将详细介绍如何利用Spring Security来实现手机验证码的注册和登录功能,帮助你在短时间内搞定这一需求。

2. 注册

2.1. 手机验证码注册流程

以下是对流程图的具体分析:

  1. 前端请求和手机号码处理

    • 用户发起获取验证码的请求,后端接收手机号码,生成随机验证码并存储在Redis中,这部分流程是标准的短信验证流程。
    • 在存储到Redis时明确了验证码的有效时间(5分钟)。
  2. 验证码发送

    • 验证码通过调用短信服务发送,这里需要自行选择像阿里云、华为云等短信发送平台。
  3. 用户验证和注册提交

    • 用户收到验证码后,在前端输入验证码并提交注册请求。
    • 系统从Redis中获取验证码并与用户输入的验证码进行匹配。
    • 如果匹配成功,注册流程继续进行并完成注册。
    • 如果匹配失败,提示用户验证码错误。

2.2. 代码实现

  1. 匹配短信消息发送相关参数(以华为云为例)

  1. 编写短信发送工具类
 

java

代码解读

复制代码

@Component public class SendSmsUtil { @Value("${huawei.sms.url}") private String url; @Value("${huawei.sms.appKey}") private String appKey; @Value("${huawei.sms.appSecret}") private String appSecret; @Value("${huawei.sms.sender}") private String sender; @Value("${huawei.sms.signature}") private String signature; /** * 无需修改,用于格式化鉴权头域,给"X-WSSE"参数赋值 */ private static final String WSSE_HEADER_FORMAT = "UsernameToken Username=\"%s\",PasswordDigest=\"%s\",Nonce=\"%s\",Created=\"%s\""; /** * 无需修改,用于格式化鉴权头域,给"Authorization"参数赋值 */ private static final String AUTH_HEADER_VALUE = "WSSE realm=\"SDP\",profile=\"UsernameToken\",type=\"Appkey\""; public void sendSms(String templateId,String receiver, String templateParas) throws IOException { String body = buildRequestBody(sender, receiver, templateId, templateParas, "", signature); String wsseHeader = buildWsseHeader(appKey, appSecret); HttpsURLConnection connection = null; OutputStreamWriter out = null; BufferedReader in = null; StringBuilder result = new StringBuilder(); try { URL realUrl = new URL(url); connection = (HttpsURLConnection) realUrl.openConnection(); connection.setDoOutput(true); connection.setDoInput(true); connection.setRequestMethod("POST"); connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); connection.setRequestProperty("Authorization", "WSSE realm=\"SDP\",profile=\"UsernameToken\",type=\"Appkey\""); connection.setRequestProperty("X-WSSE", wsseHeader); out = new OutputStreamWriter(connection.getOutputStream()); out.write(body); out.flush(); int status = connection.getResponseCode(); InputStream is; if (status == 200) { is = connection.getInputStream(); } else { is = connection.getErrorStream(); } in = new BufferedReader(new InputStreamReader(is, "UTF-8")); String line; while ((line = in.readLine()) != null) { result.append(line); } System.out.println(result.toString()); } catch (Exception e) { e.printStackTrace(); } finally { if (out != null) { out.close(); } if (in != null) { in.close(); } if (connection != null) { connection.disconnect(); } } } /** * 构造请求Body体 * @param sender * @param receiver * @param templateId * @param templateParas * @param statusCallBack * @param signature | 签名名称,使用国内短信通用模板时填写 * @return */ static String buildRequestBody(String sender, String receiver, String templateId, String templateParas, String statusCallBack, String signature) { if (null == sender || null == receiver || null == templateId || sender.isEmpty() || receiver.isEmpty() || templateId.isEmpty()) { System.out.println("buildRequestBody(): sender, receiver or templateId is null."); return null; } Map<String, String> map = new HashMap<String, String>(); map.put("from", sender); map.put("to", receiver); map.put("templateId", templateId); if (null != templateParas && !templateParas.isEmpty()) { map.put("templateParas", templateParas); } if (null != statusCallBack && !statusCallBack.isEmpty()) { map.put("statusCallback", statusCallBack); } if (null != signature && !signature.isEmpty()) { map.put("signature", signature); } StringBuilder sb = new StringBuilder(); String temp = ""; for (String s : map.keySet()) { try { temp = URLEncoder.encode(map.get(s), "UTF-8"); } catch (UnsupportedEncodingException e) { e.printStackTrace(); } sb.append(s).append("=").append(temp).append("&"); } return sb.deleteCharAt(sb.length()-1).toString(); } /** * 构造X-WSSE参数值 * @param appKey * @param appSecret * @return */ static String buildWsseHeader(String appKey, String appSecret) { if (null == appKey || null == appSecret || appKey.isEmpty() || appSecret.isEmpty()) { System.out.println("buildWsseHeader(): appKey or appSecret is null."); return null; } SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); String time = sdf.format(new Date()); //Created String nonce = UUID.randomUUID().toString().replace("-", ""); //Nonce MessageDigest md; byte[] passwordDigest = null; try { md = MessageDigest.getInstance("SHA-256"); md.update((nonce + time + appSecret).getBytes()); passwordDigest = md.digest(); } catch (NoSuchAlgorithmException e) { e.printStackTrace(); } //如果JDK版本是1.8,请加载原生Base64类,并使用如下代码 String passwordDigestBase64Str = Base64.getEncoder().encodeToString(passwordDigest); //PasswordDigest //如果JDK版本低于1.8,请加载三方库提供Base64类,并使用如下代码 //String passwordDigestBase64Str = Base64.encodeBase64String(passwordDigest); //PasswordDigest //若passwordDigestBase64Str中包含换行符,请执行如下代码进行修正 //passwordDigestBase64Str = passwordDigestBase64Str.replaceAll("[\\s*\t\n\r]", ""); return String.format(WSSE_HEADER_FORMAT, appKey, passwordDigestBase64Str, nonce, time); } /*** @throws Exception */ static void trustAllHttpsCertificates() throws Exception { TrustManager[] trustAllCerts = new TrustManager[] { new X509TrustManager() { public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { return; } public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { return; } public X509Certificate[] getAcceptedIssuers() { return null; } } }; SSLContext sc = SSLContext.getInstance("SSL"); sc.init(null, trustAllCerts, null); HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory()); } }

上述工具类 SendSmsUtil 是一个用于通过华为云短信服务发送短信验证码的工具类。它通过构建请求体和鉴权头信息,将短信发送请求发送到华为短信服务接口。该类包含了短信发送的核心逻辑,包括生成X-WSSE头用于请求认证、构造请求体以及处理HTTPS连接的相关逻辑。同时,工具类还包含了信任所有HTTPS证书的设置,以确保与华为云服务器的安全连接。

  1. 发送验证码函数方法

整理了一份核心面试笔记包括了:Java面试、Spring、JVM、MyBatis、Redis、MySQL、并发编程、微服务、Linux、Springboot、SpringCloud、MQ、Kafka 面试专题

需要全套面试笔记【点击此处即可】免费获取

java

代码解读

复制代码

public String sendSMS(SendSMSDTO sendSMSDTO) throws IOException { String phone = sendSMSDTO.getPhone(); String captcha = generateCaptcha(); String redisKey = sendSMSDTO.getCaptchaType().equals(0) ? REDIS_REGISTER_CAPTCHA_KEY + phone : REDIS_LOGIN_CAPTCHA_KEY + phone; String message = sendSMSDTO.getCaptchaType().equals(0) ? "发送注册短信验证码:{}" : "发送登录短信验证码:{}"; sendSmsUtil.sendSms(templateId, phone, "["" + captcha + ""]"); log.info(message, captcha); redisUtils.set(redisKey, captcha, 300); return "发送短信成功"; }

上述代码实现了一个短信验证码发送流程。首先,通过 generateCaptcha() 方法生成一个验证码,并调用 sendSmsUtil.sendSms() 将验证码发送到用户的手机号码。短信发送后,利用日志记录了发送的验证码。接着,验证码被存储在 Redis 中,键为手机号加上特定前缀,且设置了300秒的有效期。最后,返回一个短信发送成功的消息。

之后还有提交注册时的验证,这个较为简单,不做讲解,本来发送验证码函数我都不想写的╮(╯▽╰)╭。

3. 登录

3.1. 手机验证码登录流程

以下是对流程图的具体分析:

  1. 验证码发送流程

    • 流程依然从用户请求验证码开始,后端接收手机号并生成验证码,通过短信服务平台(如阿里云、华为云)发送验证码。
  2. 验证码验证及登录提交

    • 用户收到验证码后输入并提交登录请求,系统从Redis中获取存储的验证码,与用户输入的验证码进行匹配。
    • 如果验证码匹配失败,系统会提示用户验证码错误。
  3. 用户信息查询及Token生成

    • 当验证码匹配成功后,系统会进一步查询用户信息,检查是否存在有效的用户账号。
    • 如果用户信息存在,系统生成Token完成登录,确保用户的身份验证。

实现细节:

  • 验证码登录的核心是实现Spring Security的 AuthenticationProvider 接口,用于自定义认证逻辑。
  • 创建一个 SmsCodeAuthenticationToken,类似于 UsernamePasswordAuthenticationToken,用于存储手机号和验证码。
  • 实现自定义的 AuthenticationFilter,拦截登录请求,并将手机号和验证码封装为 SmsCodeAuthenticationToken,然后交给 AuthenticationManager 进行认证。
  • AuthenticationProvider 中验证验证码是否正确,如果正确,则返回已认证的 Authentication 对象。

3.2. 涉及到的Spring Security组件

要实现手机验证码登录,我们需要灵活使用Spring Security的认证流程,并在其中引入自定义的验证码验证逻辑。以下是关键的Spring Security组件及其在实现手机验证码登录时的作用:

  1. AuthenticationManager

AuthenticationManager 是Spring Security认证的核心组件,负责处理不同的认证请求。我们可以自定义一个 AuthenticationProvider 来处理手机验证码的认证逻辑,并将其注入到 AuthenticationManager 中。这样当用户提交验证码登录请求时, AuthenticationManager 会调用我们的自定义认证提供者进行验证。

  1. AuthenticationProvider

AuthenticationProvider 是处理认证逻辑的核心接口。为了支持手机验证码登录,我们需要实现一个自定义的 AuthenticationProvider,其中包含以下逻辑:

  • 接收包含手机号和验证码的登录请求。
  • 验证Redis中存储的验证码是否与用户输入的验证码匹配。
  • 验证成功后,创建并返回 Authentication 对象,表示用户已通过认证。
  1. UserDetailsService

UserDetailsService 是Spring Security中用于加载用户信息的接口。我们可以通过实现 UserDetailsService 来查询和加载用户信息,比如通过手机号查询用户的详细信息(包括权限、角色等)。如果用户信息存在且验证码验证通过,系统将生成相应的 UserDetails 对象,并将其与Spring Security的认证上下文进行关联。

  1. AuthenticationToken

在Spring Security中,AuthenticationToken 是认证过程中传递用户凭据的对象。我们需要自定义一个 SmsAuthenticationToken,用于封装手机号和验证码,并传递给 AuthenticationProvider 进行处理。这个Token类需要继承自 AbstractAuthenticationToken,并包含手机号和验证码信息。

  1. SecurityConfigurerAdapter

SecurityConfigurerAdapter 是Spring Security配置的核心类,用于配置Spring Security的各种安全策略。为了集成手机验证码登录,我们需要扩展 SecurityConfigurerAdapter 并在其中配置我们的 AuthenticationProvider 和自定义的登录过滤器。

  1. 自定义过滤器

UsernamePasswordAuthenticationFilter 是Spring Security默认的用户名密码认证过滤器。为了支持手机验证码登录,我们可以自定义一个类似的过滤器 SmsAuthenticationFilter,在其中获取用户的手机号和验证码,然后交给 AuthenticationManager 进行处理。这个过滤器将拦截验证码登录请求,并调用 AuthenticationProvider 进行验证。

  1. SecurityContextHolder

SecurityContextHolder 是Spring Security中用于存储当前认证信息的类。在用户成功通过验证码登录认证后,系统会将 Authentication 对象存储到 SecurityContextHolder 中,表明当前用户已经成功登录。

实现细节:

  • 验证码登录的核心是实现Spring Security的 AuthenticationProvider 接口,用于自定义认证逻辑。
  • 创建一个 SmsCodeAuthenticationToken,类似于 UsernamePasswordAuthenticationToken,用于存储手机号和验证码。
  • 实现自定义的 AuthenticationFilter,拦截登录请求,并将手机号和验证码封装为 SmsCodeAuthenticationToken,然后交给 AuthenticationManager 进行认证。
  • AuthenticationProvider 中验证验证码是否正确,如果正确,则返回已认证的 Authentication 对象。

3.3. 代码实现(仅核心)

3.3.1.  编写SmsAuthenticationFilter
 

java

代码解读

复制代码

public class SmsAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public static final String PHONE_KEY = "phone"; // 手机号字段 public static final String CAPTCHA_KEY = "captcha"; // 验证码字段 private boolean postOnly = true; private final ObjectMapper objectMapper = new ObjectMapper(); public SmsAuthenticationFilter() { super("/sms/login"); // 拦截短信验证码登录请求 } @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 phone; String captcha; try { // 读取请求体中的 JSON 数据并解析 Map<String, String> requestBody = objectMapper.readValue(request.getInputStream(), Map.class); phone = requestBody.get(PHONE_KEY); // 获取手机号 captcha = requestBody.get(CAPTCHA_KEY); // 获取验证码 } catch (IOException e) { throw new AuthenticationServiceException("Failed to parse authentication request body", e); } if (phone == null) { phone = ""; } if (captcha == null) { captcha = ""; } phone = phone.trim(); // 创建验证请求的 Token SmsAuthenticationToken authRequest = new SmsAuthenticationToken(phone, captcha); return this.getAuthenticationManager().authenticate(authRequest); } public void setPostOnly(boolean postOnly) { this.postOnly = postOnly; } }

上述代码实现了一个 SmsAuthenticationFilter,用于处理短信验证码登录请求。它继承了 AbstractAuthenticationProcessingFilter,并在接收到 POST 请求时从请求体中解析手机号和验证码的 JSON 数据,创建一个 SmsAuthenticationToken,然后通过 Spring Security 的认证管理器进行身份验证。如果请求不是 POST 方法或解析 JSON 失败,会抛出相应的异常。

3.3.2.  编写SmsAuthenticationProvider
 

java

代码解读

复制代码

public class SmsAuthenticationProvider implements AuthenticationProvider { private final UserDetailsService userDetailsService; private final RedisUtils redisUtils; public SmsAuthenticationProvider(UserDetailsService userDetailsService, RedisUtils redisUtils) { this.userDetailsService = userDetailsService; this.redisUtils = redisUtils; } @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String phone = (String) authentication.getPrincipal(); // 获取手机号 String captcha = (String) authentication.getCredentials(); // 获取验证码 if(!redisUtils.hasKey(REDIS_LOGIN_CAPTCHA_KEY + phone)){ throw new BadCredentialsException("验证码已过期"); } // 验证码是否正确 String redisCaptcha = redisUtils.get(REDIS_LOGIN_CAPTCHA_KEY + phone).toString(); if (redisCaptcha == null || !redisCaptcha.equals(captcha)) { throw new BadCredentialsException("验证码错误"); } // 验证用户信息 UserDetails userDetails = userDetailsService.loadUserByUsername(phone); if (userDetails == null) { throw new BadCredentialsException("未找到对应的用户,请先注册"); } // 创建已认证的Token return new SmsAuthenticationToken(userDetails, null, userDetails.getAuthorities()); } @Override public boolean supports(Class<?> authentication) { return SmsAuthenticationToken.class.isAssignableFrom(authentication); } }

上述代码实现了一个 SmsAuthenticationProvider,用于处理短信验证码登录的身份验证逻辑。它通过 UserDetailsService 加载用户信息,并使用 RedisUtils 从 Redis 中获取验证码进行比对。如果验证码不存在或不匹配,会抛出 BadCredentialsException 异常。如果验证码正确且用户存在,则生成已认证的 SmsAuthenticationToken 并返回,完成用户身份验证。该类还定义了它支持的身份验证类型为 SmsAuthenticationToken

3.3.3.  编写SmsAuthenticationToken
 

java

代码解读

复制代码

public class SmsAuthenticationToken extends AbstractAuthenticationToken { private final Object principal; private Object credentials; public SmsAuthenticationToken(Object principal, Object credentials) { super(null); this.principal = principal; // 用户的手机号 this.credentials = credentials; // 验证码 setAuthenticated(false); } public SmsAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) { super(authorities); this.principal = principal; this.credentials = credentials; setAuthenticated(true); } @Override public Object getCredentials() { return this.credentials; } @Override public Object getPrincipal() { return this.principal; } @Override public void eraseCredentials() { super.eraseCredentials(); this.credentials = null; } }

上述代码实现了一个自定义的 SmsAuthenticationToken,继承自 AbstractAuthenticationToken,用于表示短信验证码登录的认证信息。它包含用户的手机号 (principal) 和验证码 (credentials) 两个字段,并提供两种构造方法:一种用于未认证的登录请求,另一种用于已认证的用户信息。通过 getPrincipal() 获取手机号,getCredentials() 获取验证码,并且在调用 eraseCredentials() 时清除验证码以增强安全性。

3.3.4. 配置WebSecurityConfigurerAdapter

新增验证码过滤

 

java

代码解读

复制代码

// 添加短信验证码过滤器 http.addFilterBefore(smsAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

定义短信验证码认证过滤器,设置认证管理器及认证成功和失败的处理器。

 

java

代码解读

复制代码

@Bean public SmsAuthenticationFilter smsAuthenticationFilter() throws Exception { SmsAuthenticationFilter filter = new SmsAuthenticationFilter(); filter.setAuthenticationManager(authenticationManagerBean()); // 设置认证管理器 filter.setAuthenticationSuccessHandler(securAuthenticationSuccessHandler); // 设置成功处理器 filter.setAuthenticationFailureHandler(securAuthenticationFailureHandler); // 设置失败处理器 return filter; }

 定义短信验证码认证提供者,注入用户详情服务和 Redis 工具类,用于处理短信验证码的认证逻辑。

 

java

代码解读

复制代码

@Bean public SmsAuthenticationProvider smsAuthenticationProvider() { return new SmsAuthenticationProvider(smeUserDetailsService,redisUtils); }

配置认证管理器,添加短信验证码、微信登录以及用户名密码的认证提供者。

 

java

代码解读

复制代码

@Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { // 添加短信验证码认证提供者 auth.authenticationProvider(smsAuthenticationProvider()); // 添加微信登录认证提供者 auth.authenticationProvider(weChatAuthenticationProvider()); // 添加用户名密码登录认证提供者 auth.authenticationProvider(daoAuthenticationProvider()); }

3.4. 效果测试

基于上述的手机验证码登录代码,我们来测试一下接口成果: 

image.png

到此圆满完结✿✿ヽ(°▽°)ノ✿

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值