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

作者:后端小肥肠

🍇 我写过的文章中的相关代码放到了gitee,地址:xfc-fdw-cloud: 公共解决方案

🍊 有疑问可私信或评论区联系我。

🥑 创作不易未经允许严禁转载。

姊妹篇:

【Spring Security系列】基于Spring Security实现权限动态分配之菜单-角色分配及动态鉴权实践_spring secrity权限角色动态管理-CSDN博客

【Spring Security系列】基于Spring Security实现权限动态分配之用户-角色分配_spring security 角色-CSDN博客

【Spring Security系列】权限之旅:SpringSecurity小程序登录深度探索_spring security 微信小程序登录-CSDN博客

【Spring Security系列】Spring Security+JWT+Redis实现用户认证登录及登出_spring security jwt 退出登录-CSDN博客

目录

[1. 前言](#1. 前言)

[2. 注册](#2. 注册)

[2.1. 手机验证码注册流程](#2.1. 手机验证码注册流程)

[2.2. 代码实现(仅核心)](#2.2. 代码实现(仅核心))

[3. 登录](#3. 登录)

[3.1. 手机验证码登录流程](#3.1. 手机验证码登录流程)

[3.2. 涉及到的Spring Security组件](#3.2. 涉及到的Spring Security组件)

[3.3. 代码实现(仅核心)](#3.3. 代码实现(仅核心))

[3.3.1. 编写SmsAuthenticationFilter](#3.3.1. 编写SmsAuthenticationFilter)

[3.3.2. 编写SmsAuthenticationProvider](#3.3.2. 编写SmsAuthenticationProvider)

[3.3.3. 编写SmsAuthenticationToken](#3.3.3. 编写SmsAuthenticationToken)

[3.3.4. 配置WebSecurityConfigurerAdapter](#3.3.4. 配置WebSecurityConfigurerAdapter)

[3.4. 效果测试](#3.4. 效果测试)

[4. 结语](#4. 结语)


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 复制代码
    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完成登录,确保用户的身份验证。

3.2. 涉及到的Spring Security组件

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

1**. AuthenticationManager**

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

2.AuthenticationProvider

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

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

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

  1. AuthenticationToken

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

5.SecurityConfigurerAdapter

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

  1. 自定义过滤器

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

  1. SecurityContextHolder

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

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. 效果测试

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

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

4. 结语

通过以上步骤,我们成功实现了基于Spring Security的手机验证码登录功能。无论是注册流程中的验证码发送与验证,还是登录时的身份认证,Spring Security提供了足够的灵活性,让我们能够快速集成这项功能。在实际应用中,开发者可以根据自身需求进一步优化和扩展,比如增加更复杂的验证逻辑或增强安全性。希望本教程能帮助你轻松解决验证码登录的问题,让开发过程更加顺畅高效。

相关推荐
晴子呀16 分钟前
Spring底层原理大致脉络
java·后端·spring
只吹45°风22 分钟前
Java-ArrayList和LinkedList区别
java·arraylist·linkedlist·区别
阿华的代码王国30 分钟前
【JavaEE】多线程编程引入——认识Thread类
java·开发语言·数据结构·mysql·java-ee
黑蛋同志30 分钟前
array和linked list的区别
java
andrew_121936 分钟前
腾讯 IEG 游戏前沿技术 一面复盘
java·redis·sql·面试
andrew_121938 分钟前
腾讯 IEG 游戏前沿技术 二面复盘
后端·sql·面试
寻求出路的程序媛44 分钟前
JVM —— 类加载器的分类,双亲委派机制
java·jvm·面试
计算机程序设计开发44 分钟前
计算机毕业设计公交站点线路查询网站登录注册搜索站点线路车次/springboot/javaWEB/J2EE/MYSQL数据库/vue前后分离小程序
数据库·vue.js·spring boot·课程设计·计算机毕业设计
这孩子叫逆1 小时前
35. MyBatis中的缓存失效机制是如何工作的?
java·spring·mybatis
骆晨学长1 小时前
基于SpringBoot的校园失物招领系统
java·spring boot