手把手带你在集成SpringSecurity的SpringBoot应用中添加短信验证码登录认证功能

本文目录

    • 前言
    • 1 自定义AuthenticationToken类
    • 2 自定义AuthenticationProvider类
    • 3 自定义MobilePhoneAuthenticationFilter
    • 3 修改UserService类
    • 5 修改短信服务sendLoginVeryCodeMessage方法
    • 6 修改WebSecurityConfig配置类
    • 7 验证效果

前言

在上一篇文章一文理清SpringSecurity中基于用于名密码的登录认证流程中笔者有详细地介绍了Spring Security登录认证的流程,也为我们在工作中面需要实现自定义的登录认证如手机号+短信验证码、邮箱地址+邮箱验证码以及第三方登录认证等方式的扩展做好了准备。那么本文,笔者就手把手带大家实现在集成了Spring SecuritySpringBoot项目中如何增加一种手机号+短信验证码的方式实现登录认证。

最新为了节约搭建项目的时间成本,本文功能的实现在笔者之前改造过的开源项目 blogserver的基础上进行,项目代码地址笔者会在文末提供,希望读者们都能花个5分钟左右坚持看到文末。

1 自定义AuthenticationToken类

我们自定义的MobilePhoneAuthenticationToken类继承自AbstractAuthenticationToken类,主要提供一个带参构造方法并重写getCredentialsgetPrincipalsetAuthenticatederaseCredential和getName`等方法

public class MobilePhoneAuthenticationToken extends AbstractAuthenticationToken {
    // 登录身份,这里是手机号
    private Object principal;
    
    // 登录凭证,这里是短信验证码
    private Object credentials;
    
    /**
     * 构造方法
     * @param authorities 权限集合
     * @param principal 登录身份
     * @param credentials 登录凭据
     */
    public MobilePhoneAuthenticationToken(Collection<? extends GrantedAuthority> authorities, Object principal, Object credentials) {
        super(authorities);
        this.principal = principal;
        this.credentials = credentials;
        super.setAuthenticated(true);
    }
    
    @Override
    public Object getCredentials() {
        return credentials;
    }

    @Override
    public Object getPrincipal() {
        return principal;
    }
    // 不允许通过set方法设置认证标识
    @Override
    public void setAuthenticated(boolean authenticated) {
        if (authenticated) {
            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();
        credentials = null;
    }
    
    // 获取认证token的名字
    @Override
    public String getName() {
        return "mobilePhoneAuthenticationToken";
    }
}

2 自定义AuthenticationProvider类

我们自定义的MobilePhoneAuthenticationProvider类的时候 我们参照了AbstractUserDetailsAuthenticationProvider类的源码, 同时实现了AuthenticationProviderInitializingBeanMessageSourceAware等三个接口

同时为了实现手机号+短信验证码登录认证的功能,我们在这个类中添加了UserServiceRedisTemplate两个类属性,作为MobilePhoneAuthenticationProvider类的两个构造参数

该类的编码完成后的源码如下:

public class MobilePhoneAuthenticationProvider implements AuthenticationProvider, InitializingBean, MessageSourceAware {

    private UserService userService;

    private RedisTemplate redisTemplate;

    private boolean forcePrincipalAsString = false;

    private static final Logger logger = LoggerFactory.getLogger(MobilePhoneAuthenticationProvider.class);

    protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();

    private UserDetailsChecker userDetailsChecker = new AccountStatusUserDetailsChecker();
    
    public MobilePhoneAuthenticationProvider(UserService userService, RedisTemplate redisTemplate) {
        this.userService = userService;
        this.redisTemplate = redisTemplate;
    }
    
   /**
     * 认证方法
     * @param authentication 认证token
     * @return successAuthenticationToken
     * @throws AuthenticationException 认证异常
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        // 首先判断authentication参数必须是一个MobilePhoneAuthenticationToken类型对象
        Assert.isInstanceOf(MobilePhoneAuthenticationToken.class, authentication,
                ()-> this.messages.getMessage("MobilePhoneAuthenticationProvider.onlySupports", "Only MobilePhoneAuthenticationToken is supported"));
        // 获取authentication参数的principal属性作为手机号
        String phoneNo = authentication.getPrincipal().toString();
        if (StringUtils.isEmpty(phoneNo)) {
            logger.error("phoneNo cannot be null");
            throw new BadCredentialsException("phoneNo cannot be null");
        }
        // 获取authentication参数的credentials属性作为短信验证码
        String phoneCode = authentication.getCredentials().toString();
        if (StringUtils.isEmpty(phoneCode)) {
            logger.error("phoneCode cannot be null");
            throw new BadCredentialsException("phoneCode cannot be null");
        }
        try {
            // 调用userService服务根据手机号查询用户信息
            CustomUser user = (CustomUser) userService.loadUserByPhoneNum(Long.parseLong(phoneNo));
            // 校验用户账号是否过期、是否被锁住、是否有效等属性
            userDetailsChecker.check(user);
            // 根据手机号组成的key值去redis缓存中查询发送短信验证码时存储的验证码
            String storedPhoneCode = (String) redisTemplate.opsForValue().get("loginVerifyCode:"+phoneNo);
            if (storedPhoneCode==null) {
                logger.error("phoneCode is expired");
                throw new BadCredentialsException("phoneCode is expired");
            }
            // 用户登录携带的短信验证码与redis中根据手机号查询出来的登录认证短信验证码不一致则抛出验证码错误异常
            if (!phoneCode.equals(storedPhoneCode)) {
                logger.error("the phoneCode is not correct");
                throw new BadCredentialsException("the phoneCode is not correct");
            }
            // 把完成的用户信息赋值给组成返回认证token中的principal属性值
            Object principalToReturn = user;
            // 如果强制把用户信息转成字符串,则只返回用户的手机号码
            if(isForcePrincipalAsString()) {
                principalToReturn = user.getPhoneNum();
            }
            // 认证成功则返回一个MobilePhoneAuthenticationToken实例对象,principal属性为较为完整的用户信息
            MobilePhoneAuthenticationToken successAuthenticationToken = new MobilePhoneAuthenticationToken(user.getAuthorities(), principalToReturn, phoneCode);
            return successAuthenticationToken;
        } catch (UsernameNotFoundException e) {
            // 用户手机号不存在,如果用户已注册提示用户先去个人信息页面添加手机号码信息,否则提示用户使用手机号注册成为用户后再登录
            logger.error("user " + phoneNo + "not found, if you have been register as a user, please goto the page of edit user information to  add you phone number, " +
                    "else you must register as a user use you phone number");
            throw new BadCredentialsException("user " + phoneNo + "not found, if you have been register as a user, please goto the page of edit user information to  add you phone number, " +
                    "else you must register as a user use you phone number");
        } catch (NumberFormatException e) {
            logger.error("invalid phoneNo, due it is not a number");
            throw new BadCredentialsException("invalid phoneNo, due do phoneNo is not a number");
        }
    }
    
    /**
    * 只支持自定义的MobilePhoneAuthenticationToken类的认证
    */
    @Override
    public boolean supports(Class<?> aClass) {
        return aClass.isAssignableFrom(MobilePhoneAuthenticationToken.class);
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        Assert.notNull(this.messages, "A message source must be set");
        Assert.notNull(this.redisTemplate, "A RedisTemplate must be set");
        Assert.notNull(this.userService, "A UserDetailsService must be set");
    }

    @Override
    public void setMessageSource(MessageSource messageSource) {
        this.messages = new MessageSourceAccessor(messageSource);
    }

    public void setForcePrincipalAsString(boolean forcePrincipalAsString) {
        this.forcePrincipalAsString = forcePrincipalAsString;
    }

    public boolean isForcePrincipalAsString() {
        return forcePrincipalAsString;
    }
}

在这个自定义的认证器类中主要在authenticate方法中完成自定义的认证逻辑,最后认证成功之后返回一个新的

MobilePhoneAuthenticationToken对象,principal属性为认证通过后的用户详细信息。

3 自定义MobilePhoneAuthenticationFilter

该类我们参照UsernamePasswordAuthenticationFilter类的源码实现一个专门用于手机号+验证码登录认证的认证过滤器,它的源码如下,我们主要在attemptAuthentication方法中完成从HttpServletRequest类型请求参数中提取手机号和短信验证码等请求参数。然后组装成一个MobilePhoneAuthenticationToken对象,用于调用this.getAuthenticationManager().authenticate方法时作为参数传入。

实现重写attemptAuthentication方法后的MobilePhoneAuthenticationFilter类的源码如下:

/**
 * 自定义手机登录认证过滤器
 */
public class MobilePhoneAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    public static final String SPRING_SECURITY_PHONE_NO_KEY = "phoneNo";

    public static final String SPRING_SECURITY_PHONE_CODE_KEY = "phoneCode";

    private String phoneNoParameter = SPRING_SECURITY_PHONE_NO_KEY;

    private String phoneCodeParameter = SPRING_SECURITY_PHONE_CODE_KEY;

    private boolean postOnly = true;

    public MobilePhoneAuthenticationFilter(String defaultFilterProcessesUrl) {
        super(defaultFilterProcessesUrl);
    }

    public MobilePhoneAuthenticationFilter(RequestMatcher requiresAuthenticationRequestMatcher) {
        super(requiresAuthenticationRequestMatcher);
    }


    @Override
    public Authentication attemptAuthentication(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws AuthenticationException, IOException, ServletException {
        if (postOnly && !httpServletRequest.getMethod().equals("POST")) {
            throw new AuthenticationServiceException(
                    "Authentication method not supported: " + httpServletRequest.getMethod());
        }
        String phoneNo = obtainPhoneNo(httpServletRequest);
        if (phoneNo==null) {
            phoneNo = "";
        } else {
            phoneNo = phoneNo.trim();
        }
        String phoneCode = obtainPhoneCode(httpServletRequest);
        if (phoneCode==null) {
            phoneCode = "";
        } else {
            phoneCode = phoneCode.trim();
        }
        MobilePhoneAuthenticationToken authRequest = new MobilePhoneAuthenticationToken(new ArrayList<>(), phoneNo, phoneCode);
        this.setDetails(httpServletRequest, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    @Nullable
    protected String obtainPhoneNo(HttpServletRequest request) {
        return request.getParameter(phoneNoParameter);
    }

    @Nullable
    protected String obtainPhoneCode(HttpServletRequest request) {
        return request.getParameter(phoneCodeParameter);
    }

    protected void setDetails(HttpServletRequest request, MobilePhoneAuthenticationToken authRequest) {
        authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
    }
}

3 修改UserService类

UserService类主要在用来查询用户自定义信息,我们在该类中添加根据手机号查询用户信息方法。注意如果用户表中没有手机号码字段,需要给表新增一个存储手机号码的字段,列类型为bigint, 实体类中该字段为Long类型

UserService类中实现根据用户手机号查询用户信息的实现代码如下:

@Service
@Transactional
public class UserService implements CustomUserDetailsService {
    @Resource
    UserMapper userMapper;
    
    @Resource
    RolesMapper rolesMapper;
    
    @Resource
    PasswordEncoder passwordEncoder;
   
    private static final Logger logger = LoggerFactory.getLogger(UserService.class);
    
    /**
     * 根据用户手机号查询用户详细信息
     * @param phoneNum 手机号
     * @return customUser
     * @throws UsernameNotFoundException
     */
     @Override
    public UserDetails loadUserByPhoneNum(Long phoneNum) throws UsernameNotFoundException {
        logger.info("用户登录认证, phoneNum={}", phoneNum);
        UserDTO userDTO = userMapper.loadUserByPhoneNum(phoneNum);
        if (userDTO == null) {
            // 抛UsernameNotFoundException异常
            throw  new UsernameNotFoundException("user " + phoneNum + " not exist!");
        }
        CustomUser customUser = convertUserDTO2CustomUser(userDTO);
        return customUser;
    }
    
    /**
     * UserDTO转CustomUser对象
     * @param userDTO
     * @return user
     */
    private CustomUser convertUserDTO2CustomUser(UserDTO userDTO) {
        //查询用户的角色信息,并返回存入user中
        List<Role> roles = rolesMapper.getRolesByUid(userDTO.getId());
        // 权限大的角色排在前面
        roles.sort(Comparator.comparing(Role::getId));
        CustomUser user = new CustomUser(userDTO.getUsername(), userDTO.getPassword(),
                userDTO.getEnabled()==1, true, true,
                true, new ArrayList<>());
        user.setId(userDTO.getId());
        user.setNickname(userDTO.getNickname());
        user.setPhoneNum(userDTO.getPhoneNum());
        user.setEmail(userDTO.getEmail());
        user.setUserface(userDTO.getUserface());
        user.setRegTime(userDTO.getRegTime());
        user.setUpdateTime(userDTO.getUpdateTime());
        user.setRoles(roles);
        user.setCurrentRole(roles.get(0));
        return user;
    }
    
}

UserDTOCustomUser两个实体类源码如下:

public class UserDTO implements Serializable {

    private Long id;

    private String username;

    private String password;

    private String nickname;

    private Long phoneNum;

    // 有效标识:0-无效;1-有效
    private int enabled;

    private String email;

    private String userface;

    private Timestamp regTime;

    private Timestamp updateTime;
    // ......省略各个属性的set和get方法
}
public class CustomUser extends User {
    private Long id;
    private String nickname;
    private Long phoneNum;
    private List<Role> roles;
    // 当前角色
    private Role currentRole;
    private String email;
    private String userface;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss",timezone = "GMT+8")
    private Date regTime;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss",timezone = "GMT+8")
    private Date updateTime;

    public CustomUser(String username, String password, Collection<? extends GrantedAuthority> authorities) {
        super(username, password, authorities);
    }

    public CustomUser(String username, String password, boolean enabled, boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) {
        super(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities);
    }
    
    @Override
    @JsonIgnore
    public List<GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorities = new ArrayList<>();
        for (Role role : roles) {
            authorities.add(new SimpleGrantedAuthority("ROLE_" + role.getRoleCode()));
        }
        return authorities;
    }
    // ......省略其他属性的set和get方法
    
}

Mapper层实现根据手机号码查询用户详细信息代码如下:
UserMapper.java

@Repository
public interface UserMapper {

    UserDTO loadUserByPhoneNum(@Param("phoneNum") Long phoneNum);
    // ......省略其他抽象方法
}

UserMapper.xml

<select id="loadUserByPhoneNum" resultType="org.sang.pojo.dto.UserDTO">
        SELECT id, username, nickname,password, phoneNum, enabled, email, userface, regTime, updateTime
        FROM `user`
        WHERE phoneNum = #{phoneNum,jdbcType=BIGINT}
select>

5 修改短信服务sendLoginVeryCodeMessage方法

关于在SpringBoot项目中如何集成腾讯云短信服务实现发送短信验证码功能,可以参考我之前发表在公众号的文章SpringBoot项目中快速集成腾讯云短信SDK实现手机验证码功能

只是需要稍作修改,因为发短信验证码时要求国内手机号前缀为+86,后面接的是用户的11位手机号码。而我们的数据库中存储的是11位手机号码,使用手机号+短信验证码登录时使用的也是11位手机号码。因此将短信验证码存入redis缓存时需要将这里手机号的+86前缀去掉。

如果这里不改,那么数据库中用户的手机号码字段就要设计成一个字符串类型,前端用户登录时传入的手机号参数也应该加上+86前缀。为了避免更多地方修改,我们就在这里修改好了。

SmsService.java

public SendSmsResponse sendLoginVeryCodeMessage(String phoneNum) {
        SendSmsRequest req = new SendSmsRequest();
        req.setSenderId(null);
        req.setSessionContext(null);
        req.setSign("阿福谈Java技术栈");
        req.setSmsSdkAppid(smsProperty.getAppid());
        req.setTemplateID(SmsEnum.PHONE_CODE_LOGIN.getTemplateId());
        req.setPhoneNumberSet(new String[]{phoneNum});
        String verifyCode = getCode();
        String[] params = new String[]{verifyCode, "10"};
        req.setTemplateParamSet(params);
        logger.info("req={}", JSON.toJSONString(req));
        try {
            SendSmsResponse res = smsClient.SendSms(req);
            if ("Ok".equals(res.getSendStatusSet()[0].getCode())) {
                // 截掉+86字段,发送短信验证码成功则将验证码保存到redis缓存中(目前只针对国内短息业务)
                phoneNum = phoneNum.substring(3);
                redisTemplate.opsForValue().set("loginVerifyCode:"+phoneNum, verifyCode, 10, TimeUnit.MINUTES);
            }
            return res;
        } catch (TencentCloudSDKException e) {
            logger.error("send message failed", e);
            throw new RuntimeException("send message failed, caused by " + e.getMessage());
        }
    
    // 其他代码省略

6 修改WebSecurityConfig配置类

最后我们需要修改WebSecurityConfig配置类,定义MobilePhoneAuthenticationProviderAuthenticationManager两个类的bean方法,同时在两个configure方法中增加新的逻辑处理。

最后WebSecurityConfig配置类的完整代码如下:

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Resource
    private UserService userService;
    @Resource
    RedisTemplate<String, Object> redisTemplate;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userService);
        MobilePhoneAuthenticationProvider mobilePhoneAuthenticationProvider = this.mobilePhoneAuthenticationProvider();
        auth.authenticationProvider(mobilePhoneAuthenticationProvider);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 添加手机登录认证过滤器,在构造函数中设置拦截认证请求路径
        MobilePhoneAuthenticationFilter mobilePhoneAuthenticationFilter = new MobilePhoneAuthenticationFilter("/mobile/login");
        mobilePhoneAuthenticationFilter.setAuthenticationSuccessHandler(new FormLoginSuccessHandler());
        mobilePhoneAuthenticationFilter.setAuthenticationFailureHandler(new FormLoginFailedHandler());
        // 下面这个authenticationManager必须设置,否则在MobilePhoneAuthenticationFilter#attemptAuthentication
        // 方法中调用this.getAuthenticationManager().authenticate(authRequest)方法时会报NullPointException
        mobilePhoneAuthenticationFilter.setAuthenticationManager(authenticationManagerBean());
        mobilePhoneAuthenticationFilter.setAllowSessionCreation(true);
        http.addFilterAfter(mobilePhoneAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        // 配置跨域
        http.cors().configurationSource(corsConfigurationSource());
        // 禁用spring security框架的退出登录,使用自定义退出登录
        http.logout().disable();
        http.authorizeRequests()
                .antMatchers("/user/reg").anonymous()
                .antMatchers("/sendLoginVerifyCode").anonymous()
                .antMatchers("/doc.html").hasAnyRole("user", "admin")
                .antMatchers("/admin/**").hasRole("admin")
                ///admin/**的URL都需要有超级管理员角色,如果使用.hasAuthority()方法来配置,需要在参数中加上ROLE_,如下:hasAuthority("ROLE_超级管理员")
                .anyRequest().authenticated()//其他的路径都是登录后即可访问
                .and().formLogin().loginPage("http://localhost:3000/#/login")
                .successHandler(new FormLoginSuccessHandler())
                .failureHandler(new FormLoginFailedHandler()).loginProcessingUrl("/user/login")
                .usernameParameter("username").passwordParameter("password").permitAll()
                .and().logout().permitAll().and().csrf().disable().exceptionHandling().accessDeniedHandler(getAccessDeniedHandler());
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/blogimg/**","/index.html","/static/**");
    }

    @Bean
    AccessDeniedHandler getAccessDeniedHandler() {
        return new AuthenticationAccessDeniedHandler();
    }

    //配置跨域访问资源
    private CorsConfigurationSource corsConfigurationSource() {
        UrlBasedCorsConfigurationSource source =   new UrlBasedCorsConfigurationSource();
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.addAllowedOrigin("*");	//同源配置,*表示任何请求都视为同源,若需指定ip和端口可以改为如“localhost:8080”,多个以“,”分隔;
        corsConfiguration.addAllowedHeader("*");//header,允许哪些header,本案中使用的是token,此处可将*替换为token;
        corsConfiguration.addAllowedMethod("*");	//允许的请求方法,PSOT、GET等
        corsConfiguration.setAllowCredentials(true);
        // 注册跨域配置
        source.registerCorsConfiguration("/**",corsConfiguration); //配置允许跨域访问的url
        return source;
    }

    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
    
    @Bean
    public MobilePhoneAuthenticationProvider mobilePhoneAuthenticationProvider() {
        MobilePhoneAuthenticationProvider mobilePhoneAuthenticationProvider = new MobilePhoneAuthenticationProvider(userService, redisTemplate);
        return mobilePhoneAuthenticationProvider;
    }
}

7 验证效果

编码完成后,我们在启动Mysql服务器和Redis服务器后启动我们的SpringBoot项目

首先在Postman中调用发送短信验证码接口

手把手带你在集成SpringSecurity的SpringBoot应用中添加短信验证码登录认证功能_第1张图片
验证码发送成功后返回如下响应信息:

{
    "status": 200,
    "msg": "success",
    "data": {
        "code": "Ok",
        "phoneNumber": "+8618682244076",
        "fee": 1,
        "message": "send success"
    }
}

同时手机上也会受到6位短信验证码,有效期10分钟

然后我们使用自己的手机号+收到的6位短信验证码调用登录接口

手把手带你在集成SpringSecurity的SpringBoot应用中添加短信验证码登录认证功能_第2张图片
登录成功后返回如下响应信息:

{
    "msg": "login success",
    "userInfo": {
        "accountNonExpired": true,
        "accountNonLocked": true,
        "authorities": [
            {
                "authority": "ROLE_admin"
            },
            {
                "authority": "ROLE_user"
            },
            {
                "authority": "ROLE_test1"
            }
        ],
        "credentialsNonExpired": true,
        "currentRole": {
            "id": 1,
            "roleCode": "admin",
            "roleName": "管理员"
        },
        "email": "[email protected]",
        "enabled": true,
        "id": 3,
        "nickname": "程序员阿福",
        "phoneNum": 18682244076,
        "regTime": 1624204813000,
        "roles": [
            {
                "$ref": "$.userInfo.currentRole"
            },
            {
                "id": 2,
                "roleCode": "user",
                "roleName": "普通用户"
            },
            {
                "id": 3,
                "roleCode": "test1",
                "roleName": "测试角色1"
            }
        ],
        "username": "heshengfu"
    },
    "status": "success"
}

到这里,实现在集成SpringSecurity的SpringBoot应用中增加手机号+短信码的方式登录认证的功能也就实现了。各位读者朋友如果觉得文章对你有帮助,欢迎给我的这篇文章点个在看并转发给身边的程序员同事和朋友,谢谢!后面有时间笔者会在前端用户登录界面调用本次实现的后台接口实现手机号+短信验证码功能。

以下是这边文章在本人的gitee仓库的源码地址,需要研究的完整代码的朋友可以克隆到自己本地。

blogserver项目gitee克隆地址: https://gitee.com/heshengfu1211/blogserver.git

本文首发个人微信公众号【阿福谈Web编程】,觉得我的文章对你有帮助或者有什么疑问需要与我进行交流的读者朋友欢迎关注我的微信公众号。关注后可在我的微信公众号菜单栏里点击【联系作者】,就会发送笔者的微信二维码给你。笔者期待在技术精进的路上遇到越来越多同行的盆友,让我们在IT技术学习的路上不孤单!

你可能感兴趣的:(springboot项目实战,spring,boot,spring,security,短信验证码登录)