1. 前言
在当今的互联网应用中,手机验证码登录已经成为一种常见的用户身份验证方式。相比传统的用户名密码登录方式,手机验证码具有使用方便、安全性较高的特点。对于开发者来说,如何在现有的系统中快速集成这一功能,尤其是在Spring Security框架下,可能是一个具有挑战性的任务。这篇文章将详细介绍如何利用Spring Security来实现手机验证码的注册和登录功能,帮助你在短时间内搞定这一需求。
2. 注册
2.1. 手机验证码注册流程
以下是对流程图的具体分析:
-
前端请求和手机号码处理:
- 用户发起获取验证码的请求,后端接收手机号码,生成随机验证码并存储在Redis中,这部分流程是标准的短信验证流程。
- 在存储到Redis时明确了验证码的有效时间(5分钟)。
-
验证码发送:
- 验证码通过调用短信服务发送,这里需要自行选择像阿里云、华为云等短信发送平台。
-
用户验证和注册提交:
- 用户收到验证码后,在前端输入验证码并提交注册请求。
- 系统从Redis中获取验证码并与用户输入的验证码进行匹配。
- 如果匹配成功,注册流程继续进行并完成注册。
- 如果匹配失败,提示用户验证码错误。
2.2. 代码实现
- 匹配短信消息发送相关参数(以华为云为例)
- 编写短信发送工具类
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证书的设置,以确保与华为云服务器的安全连接。
- 发送验证码函数方法
整理了一份核心面试笔记包括了: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. 手机验证码登录流程
以下是对流程图的具体分析:
-
验证码发送流程:
- 流程依然从用户请求验证码开始,后端接收手机号并生成验证码,通过短信服务平台(如阿里云、华为云)发送验证码。
-
验证码验证及登录提交:
- 用户收到验证码后输入并提交登录请求,系统从Redis中获取存储的验证码,与用户输入的验证码进行匹配。
- 如果验证码匹配失败,系统会提示用户验证码错误。
-
用户信息查询及Token生成:
- 当验证码匹配成功后,系统会进一步查询用户信息,检查是否存在有效的用户账号。
- 如果用户信息存在,系统生成Token完成登录,确保用户的身份验证。
实现细节:
- 验证码登录的核心是实现Spring Security的 AuthenticationProvider 接口,用于自定义认证逻辑。
- 创建一个 SmsCodeAuthenticationToken,类似于 UsernamePasswordAuthenticationToken,用于存储手机号和验证码。
- 实现自定义的 AuthenticationFilter,拦截登录请求,并将手机号和验证码封装为 SmsCodeAuthenticationToken,然后交给 AuthenticationManager 进行认证。
- AuthenticationProvider 中验证验证码是否正确,如果正确,则返回已认证的 Authentication 对象。
3.2. 涉及到的Spring Security组件
要实现手机验证码登录,我们需要灵活使用Spring Security的认证流程,并在其中引入自定义的验证码验证逻辑。以下是关键的Spring Security组件及其在实现手机验证码登录时的作用:
- AuthenticationManager
AuthenticationManager
是Spring Security认证的核心组件,负责处理不同的认证请求。我们可以自定义一个 AuthenticationProvider
来处理手机验证码的认证逻辑,并将其注入到 AuthenticationManager
中。这样当用户提交验证码登录请求时, AuthenticationManager
会调用我们的自定义认证提供者进行验证。
- AuthenticationProvider
AuthenticationProvider
是处理认证逻辑的核心接口。为了支持手机验证码登录,我们需要实现一个自定义的 AuthenticationProvider
,其中包含以下逻辑:
- 接收包含手机号和验证码的登录请求。
- 验证Redis中存储的验证码是否与用户输入的验证码匹配。
- 验证成功后,创建并返回
Authentication
对象,表示用户已通过认证。
- UserDetailsService
UserDetailsService
是Spring Security中用于加载用户信息的接口。我们可以通过实现 UserDetailsService
来查询和加载用户信息,比如通过手机号查询用户的详细信息(包括权限、角色等)。如果用户信息存在且验证码验证通过,系统将生成相应的 UserDetails
对象,并将其与Spring Security的认证上下文进行关联。
- AuthenticationToken
在Spring Security中,AuthenticationToken
是认证过程中传递用户凭据的对象。我们需要自定义一个 SmsAuthenticationToken
,用于封装手机号和验证码,并传递给 AuthenticationProvider
进行处理。这个Token类需要继承自 AbstractAuthenticationToken
,并包含手机号和验证码信息。
- SecurityConfigurerAdapter
SecurityConfigurerAdapter
是Spring Security配置的核心类,用于配置Spring Security的各种安全策略。为了集成手机验证码登录,我们需要扩展 SecurityConfigurerAdapter
并在其中配置我们的 AuthenticationProvider
和自定义的登录过滤器。
- 自定义过滤器
UsernamePasswordAuthenticationFilter
是Spring Security默认的用户名密码认证过滤器。为了支持手机验证码登录,我们可以自定义一个类似的过滤器 SmsAuthenticationFilter
,在其中获取用户的手机号和验证码,然后交给 AuthenticationManager
进行处理。这个过滤器将拦截验证码登录请求,并调用 AuthenticationProvider
进行验证。
- 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. 效果测试
基于上述的手机验证码登录代码,我们来测试一下接口成果:
到此圆满完结✿✿ヽ(°▽°)ノ✿