java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > Spring Security验证码登录

使用Spring Security集成手机验证码登录功能实现

作者:后端小肥肠

本文详细介绍了如何利用SpringSecurity来实现手机验证码的注册和登录功能,在登录过程中,同样需通过验证码进行验证,文章还提供了相关的代码实现

1. 前言

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

2. 注册

2.1. 手机验证码注册流程

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

前端请求和手机号码处理

验证码发送

用户验证和注册提交

2.2. 代码实现(仅核心)

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

2. 编写短信发送工具类

@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证书的设置,以确保与华为云服务器的安全连接。 

3. 发送验证码函数方法

    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. 手机验证码登录流程

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

验证码发送流程

验证码验证及登录提交

用户信息查询及Token生成

3.2. 涉及到的Spring Security组件

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

1. AuthenticationManager

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

2. AuthenticationProvider

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

3. UserDetailsService

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

4. AuthenticationToken

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

5. SecurityConfigurerAdapter

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

6. 自定义过滤器

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

7. SecurityContextHolder

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

3.3. 代码实现(仅核心)

3.3.1.  编写SmsAuthenticationFilter

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

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

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

新增验证码过滤

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

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

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

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

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

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

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

3.4. 效果测试

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

4. 结语

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

到此这篇关于如何用Spring Security集成手机验证码登录的文章就介绍到这了,更多相关Spring Security验证码登录内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文