RuoYi前后端分离框架集成手机短信验证码(一)之后端篇

一、背景

本项目基于RuoYi 3.8.9前后端分离框架构建,采用Spring Security实现系统权限管理。作为企业级应用架构的子模块,系统需要与顶层项目实现用户数据无缝对接(以手机号作为统一用户标识),同时承担用户信息采集的重要职能。为此,我们在保留原有账号密码登录方式的基础上,创新性地集成了手机号验证码登录/注册功能,既满足了企业级用户管理的标准化需求,又优化了终端用户的使用体验。

二、短信集成

短信集成可以直接使用短信供应商的SDK,公司目前采购的阿里云短信,短信集成可以直接参照阿里云短信官方文档。当然也可以采用其他更通用一点的集成方式,本人秉持着不重复造轮子同时方便后期短信供应商的变更不再次添加供应商代码,直接采用开源的短信集成工具SM4J,需要了解的可以查看SMS4J官方文档,集成过程如下:

1.添加maven依赖,直接上最新的发布版本:

            <dependency>
                <groupId>org.dromara.sms4j</groupId>
                <artifactId>sms4j-spring-boot-starter</artifactId>
                <version>3.3.5</version>
            </dependency>

2.添加短信配置:

#短信配置
sms:
  # 标注从yml读取配置
  config-type: yaml
  HttpLog: true
  corePoolSize: 2
  maxPoolSize: 6
  queueCapacity: 200
  blends:
    # 自定义的标识,也就是configId这里可以是任意值(最好不要是中文)
    alibaba:
      #框架定义的厂商名称标识
      supplier: alibaba
      #有些称为accessKey有些称之为apiKey,也有称为sdkKey或者appId。
      access-key-id: 
      #称为accessSecret有些称之为apiSecret。
      access-key-secret:
      #您的短信签名
      signature: 
      #模板ID 如果不需要简化的sendMessage方法可以不配置
      template-id: 
      # 随机权重,负载均衡的权重值依赖于此,默认为1,如不需要负载均衡可不进行配置
      weight: 1
      #配置标识名称 如果你使用的yml进行配置,则blends下层配置的就是这个,可为空,如果你使用的接口类配置,则需要设置值
      #需要注意的是,不同的配置之间config-id不能重复,否则会发生配置丢失的问题
      config-id: alibaba
      #短信自动重试间隔时间  默认五秒
      retry-interval: 10
      # 短信重试次数,默认0次不重试,如果你需要短信重试则根据自己的需求修改值即可
      max-retries: 2

3.短信发送工具:

package com.book.framework.sms;

import com.book.common.constant.CacheConstants;
import com.book.common.core.redis.RedisCache;
import lombok.extern.slf4j.Slf4j;
import org.dromara.sms4j.api.entity.SmsResponse;
import org.dromara.sms4j.core.factory.SmsFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.LinkedHashMap;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;

/**
 * @className: SmsService
 * @author: liuyh
 * @date: 2025/5/21 17:57
 * @Version: 1.0
 */
@Slf4j
@Service
public class SmsService {
    /**
     * 短信服务提供商
     * {@value CONFIG_ID}
     */
    private static final String CONFIG_ID = "alibaba";

    @Autowired
    private RedisCache redisCache;

    /**
     * 发送短信
     *
     * @param phoneNumber
     * @param message
     * @return
     */
    public boolean sendSms(String phoneNumber, String message) {
        SmsResponse smsResponse = SmsFactory.getSmsBlend(CONFIG_ID).sendMessage(phoneNumber, message);
        boolean beSent = smsResponse.isSuccess();
        if (!beSent) {
            log.info("短信服务商错误响应原始消息体: {}", smsResponse.getData());
        }
        return beSent;
    }

    /**
     * 发送短信
     *
     * @param phoneNumber
     * @param messages
     * @return
     */
    public boolean sendSms(String phoneNumber, LinkedHashMap<String, String> messages) {
        SmsResponse smsResponse = SmsFactory.getSmsBlend(CONFIG_ID).sendMessage(phoneNumber, messages);
        boolean beSent = smsResponse.isSuccess();
        if (!beSent) {
            log.info("短信服务商错误响应原始消息体: {}", smsResponse.getData());
        }
        return smsResponse.isSuccess();
    }

    /**
     * 发送手机验证方法
     *
     * @param phoneNumber
     * @return
     */
    public boolean sendVerificationCode(String phoneNumber) {
        String code = this.generateAndStoreCode(phoneNumber);
        LinkedHashMap<String, String> messages = new LinkedHashMap<>();
        messages.put("code", code);
        return this.sendSms(phoneNumber, messages);
    }

    /**
     * 生成6位随机验证码并存入Redis
     * <br>
     * <b>默认5分钟过期</b>
     *
     * @param phoneNumber 手机号
     * @return 生成的验证码
     */
    private String generateAndStoreCode(String phoneNumber) {
        int code = ThreadLocalRandom.current().nextInt(100000, 999999);
        String codeStr = String.valueOf(code);
        String key = CacheConstants.CAPTCHA_PHONE_CODE_KEY + phoneNumber;
        redisCache.setCacheObject(key, codeStr, 5, TimeUnit.MINUTES);
        return codeStr;
    }


}

至此发送短信的准备工作就完成了。

三、登录集成

若依框架安全管控是采用Spring Security实现的,如有需要进一步了解Spring Security的可以移步官方文档查看,话不多说,直接开干。

1.提供一个基于电话号码查询用户的UserDetailsService,代码如下:

package com.book.framework.sms;


import com.book.common.core.domain.entity.SysUser;
import com.book.common.core.domain.model.LoginUser;
import com.book.common.enums.UserStatus;
import com.book.common.exception.base.BaseException;
import com.book.common.utils.StringUtils;
import com.book.framework.web.service.SysPermissionService;
import com.book.system.service.ISysUserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

/**
 * 用户验证处理
 *
 * @className: SmsUserDetailsServiceImpl
 * @author: liuyh
 * @date: 2025/5/22 10:44
 * @Version: 1.0
 */
@Slf4j
@Service("userDetailsByUserPhone")
public class SmsUserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private ISysUserService userService;

    @Autowired
    private SysPermissionService permissionService;

    @Override
    public UserDetails loadUserByUsername(String phone) throws UsernameNotFoundException {
        SysUser user = userService.selectUserByUserPhone(phone);
        if (StringUtils.isNull(user)) {
            log.info("登录用户:{} 不存在.", phone);
            throw new UsernameNotFoundException("登录用户:" + phone + " 不存在");
        } else if (UserStatus.DELETED.getCode().equals(user.getDelFlag())) {
            log.info("登录用户:{} 已被删除.", phone);
            throw new BaseException("对不起,您的账号:" + phone + " 已被删除");
        } else if (UserStatus.DISABLE.getCode().equals(user.getStatus())) {
            log.info("登录用户:{} 已被停用.", phone);
            throw new BaseException("对不起,您的账号:" + phone + " 已停用");
        }

        return createLoginUser(user);
    }

    public UserDetails createLoginUser(SysUser user) {
        return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
    }
}

2.提供一个基于电话号码身份验证的验证器AuthenticationProvider

package com.book.framework.security.provider;


import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;

/**
 * 短信登陆鉴权 Provider,要求实现 AuthenticationProvider 接口
 *
 * @className: SmsCodeAuthenticationProvider
 * @author: liuyh
 * @date: 2025/5/22 10:41
 * @Version: 1.0
 */
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {

    private UserDetailsService userDetailsService;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        SmsCodeAuthenticationToken authenticationToken = (SmsCodeAuthenticationToken) authentication;

        String telephone = (String) authenticationToken.getPrincipal();

        UserDetails userDetails = userDetailsService.loadUserByUsername(telephone);

        // 此时鉴权成功后,应当重新 new 一个拥有鉴权的 authenticationResult 返回
        SmsCodeAuthenticationToken authenticationResult = new SmsCodeAuthenticationToken(userDetails,
                userDetails.getAuthorities());

        authenticationResult.setDetails(authenticationToken.getDetails());

        return authenticationResult;
    }


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

    public UserDetailsService getUserDetailsService() {
        return userDetailsService;
    }

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

3.提供一个用于校验的AbstractAuthenticationToken子类

package com.book.framework.security.provider;


import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.SpringSecurityCoreVersion;

import java.util.Collection;

/**
 * 短信登录 AuthenticationToken,模仿 UsernamePasswordAuthenticationToken 实现
 *
 * @className: SmsCodeAuthenticationToken
 * @author: liuyh
 * @date: 2025/5/22 10:42
 * @Version: 1.0
 */
public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken {

    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

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

    private final Object code;

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

    /**
     * 构建拥有鉴权的 SmsCodeAuthenticationToken
     */
    public SmsCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities,
                                      Object code) {
        super(authorities);
        this.principal = principal;
        // must use super, as we override
        this.code = code;
        super.setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return null;
    }

    @Override
    public Object getPrincipal() {
        return this.principal;
    }

    public Object getCode() {
        return code;
    }

    @Override
    public void setAuthenticated(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();
    }

}

 4.提供一个短信验证码登录业务类

package com.book.framework.sms;

import com.book.common.constant.CacheConstants;
import com.book.common.constant.Constants;
import com.book.common.core.domain.entity.SysUser;
import com.book.common.core.domain.model.LoginUser;
import com.book.common.core.redis.RedisCache;
import com.book.common.exception.user.CaptchaException;
import com.book.common.exception.user.CaptchaExpireException;
import com.book.common.exception.user.UserPhoneNotExistsException;
import com.book.common.utils.DateUtils;
import com.book.common.utils.MessageUtils;
import com.book.common.utils.ip.IpUtils;
import com.book.framework.manager.AsyncManager;
import com.book.framework.manager.factory.AsyncFactory;
import com.book.framework.security.context.AuthenticationContextHolder;
import com.book.framework.security.provider.SmsCodeAuthenticationToken;
import com.book.framework.web.service.TokenService;
import com.book.system.service.ISysUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;

/**
 * 登录校验方法
 *
 * @author ruoyi
 */
@Component
public class SmsLoginService {
    @Autowired
    private TokenService tokenService;

    @Resource
    private AuthenticationManager authenticationManager;

    @Autowired
    private RedisCache redisCache;

    @Autowired
    private ISysUserService userService;

    /**
     * 手机号登录验证
     *
     * @param phoneNumber
     * @param code
     * @return
     */
    public String login(String phoneNumber, String code) {
        this.checkMessageCaptcha(phoneNumber, code);

        // 2.用户存在, 用户验证
        Authentication authentication = null;
        try {
            // 使用自定义的toke鉴权器构造一个没有鉴权的token
            SmsCodeAuthenticationToken authenticationToken = new SmsCodeAuthenticationToken(phoneNumber, code);
            AuthenticationContextHolder.setContext(authenticationToken);
            // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
            authentication = authenticationManager.authenticate(authenticationToken);
        } catch (Exception e) {
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(phoneNumber, Constants.LOGIN_FAIL, e.getMessage()));
        } finally {
            AuthenticationContextHolder.clearContext();
        }
        AsyncManager.me().execute(AsyncFactory.recordLogininfor(phoneNumber, Constants.LOGIN_SUCCESS,
                MessageUtils.message("user.login.success")));
        if (authentication == null) {
            throw new UserPhoneNotExistsException();
        }
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        recordLoginInfo(loginUser.getUserId());

        // 生成token
        return tokenService.createToken(loginUser);

    }

    /**
     * 校验验证码
     *
     * @param phoneNumber
     * @param code
     */
    public void checkMessageCaptcha(String phoneNumber, String code) {
        String verifyKey = CacheConstants.CAPTCHA_PHONE_CODE_KEY + phoneNumber;
        String captcha = redisCache.getCacheObject(verifyKey);
        if (captcha == null) {
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(phoneNumber, Constants.LOGIN_FAIL,
                    MessageUtils.message("user.jcaptcha.expire")));
            throw new CaptchaExpireException();
        }
        redisCache.deleteObject(verifyKey);
        if (!code.equalsIgnoreCase(captcha)) {
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(phoneNumber, Constants.LOGIN_FAIL,
                    MessageUtils.message("user.jcaptcha.error")));
            throw new CaptchaException();
        }
    }

    /**
     * 记录登录信息
     *
     * @param userId 用户ID
     */
    public void recordLoginInfo(Long userId) {
        SysUser sysUser = new SysUser();
        sysUser.setUserId(userId);
        sysUser.setLoginIp(IpUtils.getIpAddr());
        sysUser.setLoginDate(DateUtils.getNowDate());
        userService.updateUserProfile(sysUser);
    }
}

5.提供一个异常类,并配置对应的提示消息

package com.book.common.exception.user;

/**
 * 用户电话号码不存在异常类
 * 
 * @author ruoyi
 */
public class UserPhoneNotExistsException extends UserException
{
    private static final long serialVersionUID = 1L;

    public UserPhoneNotExistsException()
    {
        super("user.phone.not.exists", null);
    }
}

6.提供一个登录数据映射模型 

package com.book.framework.sms;

import com.book.common.annotation.encrypt.EncryptedField;
import com.book.common.xss.Xss;
import lombok.Data;

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
import java.io.Serializable;

/**
 * @className: SmsLoginBody
 * @author: liuyh
 * @date: 2025/5/22 10:05
 * @Version: 1.0
 */
@Data
public class SmsLoginBody implements Serializable {
    private static final long serialVersionUID = 1L;

    @Xss(message = "手机号不能出现任何脚本")
    @NotBlank(message = "手机号不能为空")
    @Size(min = 11, max = 11, message = "手机号码长度不能超过11个字符")
    @Pattern(regexp = "^1[3-9]\d{9}$", message = "手机号码格式不正确")
    private String phonenumber;

    @Xss(message = "验证码不能出现任何脚本")
    @Size(min = 6, max = 6, message = "验证码长度为6个字符")
    @Pattern(regexp = "^[0-9]+$", message = "验证码应为纯数字,格式不正确")
    private String code;
}

7.添加验证码发送接口+短信验证码登录接口(SysLoginController)

/**
     * 发送短信验证码
     *
     * @param phoneNumber 手机号
     * @return AjaxResult
     */
    @PostMapping("/sms/send")
    public AjaxResult sendBySms(@RequestBody String phoneNumber) {
        if (StringUtils.isBlank(phoneNumber) || phoneNumber.length() != 11 || !phoneNumber.matches("^1[3-9]\d{9}$")) {
            return AjaxResult.error("请输入有效的长度为11位的手机号");
        }
        if (!smsService.sendVerificationCode(phoneNumber)) {
            return AjaxResult.error("验证码发送失败,请稍后重试");
        }
        return AjaxResult.success("验证码发送成功");
    }

    /**
     * 手机号登录
     *
     * @param smsLoginBody 登录信息
     * @return 结果
     */
    @PostMapping("/sms/login")
    public AjaxResult loginBySms(@Validated @RequestBody SmsLoginBody smsLoginBody) {
        AjaxResult ajax = AjaxResult.success();
        // 生成令牌
        String token = smsLoginService.login(smsLoginBody.getPhonenumber(), smsLoginBody.getCode());
        ajax.put(Constants.TOKEN, token);
        return ajax;
    }

至此发送验证和手机+验证登录后端工作已经完成,如图:

四、注册集成

提供手机号+验证码的注册方式,方便移动端用户在查看分享的电子书当没有注册时,用户可以自行注册,主要是提供给用户端使用,过程如下:

1.提供一个注册数据映射模型

package com.book.framework.sms;

import com.book.common.xss.Xss;
import lombok.Data;

import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
import java.io.Serializable;

/**
 * 移动端用户注册对象
 *
 * @author ruoyi
 */
@Data
public class AppRegisterBody implements Serializable {

    private static final long serialVersionUID = 1L;

    @Xss(message = "手机号不能出现任何脚本")
    @Size(min = 11, max = 11, message = "手机号码长度为11个字符")
    @Pattern(regexp = "^1[3-9]\d{9}$", message = "手机号码格式不正确")
    private String phonenumber;

    /**
     * 短信验证码
     */
    @Xss(message = "验证码不能出现任何脚本")
    @Size(min = 6, max = 6, message = "验证码长度为6个字符")
    @Pattern(regexp = "^[0-9]+$", message = "验证码应为纯数字,格式不正确")
    private String code;

}

2.提供一个注册方法(SysRegisterService)

 /**
     * 手机+短信验证码注册
     *
     * @param appRegisterBody
     * @return
     */
    public String appRegister(AppRegisterBody appRegisterBody) {
        String msg = "", username = USERNAME_PREFIX + appRegisterBody.getPhonenumber();
        SysUser sysUser = new SysUser();
        sysUser.setUserName(username);
        sysUser.setPhonenumber(appRegisterBody.getPhonenumber());

        // 手机短信验证码开关
        validateCaptcha(appRegisterBody.getPhonenumber(), appRegisterBody.getCode());

        if (!userService.checkPhoneUnique(sysUser)) {
            msg = "保存用户'" + appRegisterBody.getPhonenumber() + "'失败,手机号已存在";
        } else {
            sysUser.setNickName(username);
            sysUser.setCreateBy(username);
            boolean regFlag = userService.registerUser(sysUser);
            if (!regFlag) {
                msg = "注册失败,请联系系统管理人员";
            } else {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.REGISTER,
                        MessageUtils.message("user.register.success")));
            }
        }
        return msg;
    }

提供一个API接口(SysRegisterController)

/**
     * app端:手机号+短信验证码注册
     *
     * @param appUser
     * @return
     */
    @PostMapping("/app/register")
    public AjaxResult appRegister(@Validated @RequestBody AppRegisterBody appUser) {
        if (!("true".equals(configService.selectConfigByKey("sys.account.registerUser")))) {
            return error("当前系统没有开启注册功能!");
        }
        String msg = registerService.appRegister(appUser);
        return StringUtils.isEmpty(msg) ? success() : error(msg);
    }

五、附言

在新增基于电话号码查询的UserDetailsService实现后,需要为实现类分别指定实例bean的名称,防止依赖注入异常,如图:

ISysUserService接口需要实现selectUserByUserPhone方法,如图:

© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容