springboot 扩展手机短信验证码登录+vue 前台实现

1,343 阅读4分钟

今天给大家讲一下,在springboot框架中如何扩展增加手机短信验证码登录功能

SmsAuthenticationToken

步骤:

principal 原本代表用户名,这里保留,只是代表了手机号码。 credentials 原本代码密码,短信登录用不到,直接删掉。 SmsCodeAuthenticationToken() 两个构造方法一个是构造没有鉴权的,一个是构造有鉴权的。 剩下的几个方法去除无用属性即可。

package com.jjxt.b.config;

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

import java.util.Collection;

public class SmsCodeAuthenticationToken extends AbstractAuthenticationToken { private static final long serialVersionUID = 420L;
    private final Object principal;

    /**
     * 没登录之前,principal我们使用手机号
     * @param mobile
     */
    public SmsCodeAuthenticationToken(String mobile) {
        super((Collection)null);
        this.principal = mobile;
        this.setAuthenticated(false);
    }

    /**
     * 登录认证之后,principal我们使用用户信息
     * @param principal
     * @param authorities
     */
    public SmsCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        super.setAuthenticated(true);
    }
    public Object getCredentials() {
        return null;
    }
    public Object getPrincipal() {
        return this.principal;
    }
    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");
        } else {
            super.setAuthenticated(false);
        }
    }
    public void eraseCredentials() {
        super.eraseCredentials();
    }
}

SmsAuthenticationFilter

原本的静态字段有 username 和 password,都干掉,换成我们的手机号字段。 SmsCodeAuthenticationFilter() 中指定了这个 filter 的拦截 Url,我指定为 post 方式的 /sms/login。 剩下来的方法把无效的删删改改就好了。

package com.jjxt.b.config;

import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.util.Assert;
import org.springframework.web.bind.annotation.RequestBody;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class SmsCodeAuthenticationFilter  extends AbstractAuthenticationProcessingFilter {
    /**
     * form表单中手机号码的字段name
     */
    public static final String SPRING_SECURITY_FORM_MOBILE_KEY = "mobile";

    private String mobileParameter = SPRING_SECURITY_FORM_MOBILE_KEY;
    /**
     * 是否仅 POST 方式
     */
    private boolean postOnly = true;

    public SmsCodeAuthenticationFilter() {
        // 短信登录的请求 post 方式的 /sms/login
        super(new AntPathRequestMatcher("/api/sms/login", "POST"));
    }

    @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());
        }

        System.out.println( request.getParameter("mobile"));
        String mobile = obtainMobile(request);

        if (mobile == null) {
            mobile = "";
        }

        mobile = mobile.trim();

        SmsCodeAuthenticationToken authRequest = new SmsCodeAuthenticationToken(mobile);

        // Allow subclasses to set the "details" property
        setDetails(request, authRequest);

        return this.getAuthenticationManager().authenticate(authRequest);
    }

    protected String obtainMobile(HttpServletRequest request) {
        return request.getParameter(mobileParameter);
    }

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

    public String getMobileParameter() {
        return mobileParameter;
    }

    public void setMobileParameter(String mobileParameter) {
        Assert.hasText(mobileParameter, "Mobile parameter must not be empty or null");
        this.mobileParameter = mobileParameter;
    }

    public void setPostOnly(boolean postOnly) {
        this.postOnly = postOnly;
    }
}

SmsAuthenticationProvider

步骤:

实现 AuthenticationProvider 接口,实现 authenticate() 和 supports() 方法。 supports() 方法决定了这个 Provider 要怎么被 AuthenticationManager 挑中,我这里通过 return SmsCodeAuthenticationToken.class.isAssignableFrom(authentication),处理所有 SmsCodeAuthenticationToken 及其子类或子接口。 authenticate() 方法处理验证逻辑。 首先将 authentication 强转为 SmsCodeAuthenticationToken。 从中取出登录的 principal,也就是手机号。 调用自己写的 checkSmsCode() 方法,进行验证码校验,如果不合法,抛出 AuthenticationException 异常。 如果此时仍然没有异常,通过调用 loadUserByUsername(mobile) 读取出数据库中的用户信息。 如果仍然能够成功读取,没有异常,这里验证就完成了。 重新构造鉴权后的 SmsCodeAuthenticationToken,并返回给 SmsCodeAuthenticationFilter 。 SmsCodeAuthenticationFilter 的父类在 doFilter() 方法中处理是否有异常,是否成功,根据处理结果跳转到登录成功/失败逻辑

package com.jjxt.b.config;

import lombok.Data;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
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;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.util.Map;

@Data
public class SmsCodeAuthenticationProvider implements AuthenticationProvider {

    private UserDetailsService userDetailsService;

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

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

        checkSmsCode(mobile);

        UserDetails userDetails = userDetailsService.loadUserByUsername(mobile);

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

        authenticationResult.setDetails(authenticationToken.getDetails());

        return authenticationResult;
    }

    private void checkSmsCode(String mobile) {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        String inputCode = request.getParameter("code");

        Map<String, Object> smsCode = (Map<String, Object>) request.getSession().getAttribute("smscode");
        if(smsCode == null) {
            throw new BadCredentialsException("未检测到申请验证码");
        }

        String applyMobile = (String) smsCode.get("mobile");
        int code = (int) smsCode.get("code");

        if(!applyMobile.equals(mobile)) {
            throw new BadCredentialsException("申请的手机号码与登录手机号码不一致");
        }
        if(code != Integer.parseInt(inputCode)) {
            throw new BadCredentialsException("验证码错误");
        }
    }

    @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;
    }
}


成功与失败处理逻辑

package com.jjxt.b.config.security;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.pdfbox.jbig2.util.log.Logger;
import org.apache.pdfbox.jbig2.util.log.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Autowired
    private ObjectMapper objectMapper;

    private Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        logger.info("登录成功");

        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(authentication));
    }
}

验证失败处理

package com.jjxt.b.config.security;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.pdfbox.jbig2.util.log.Logger;
import org.apache.pdfbox.jbig2.util.log.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
    @Autowired
    private ObjectMapper objectMapper;

    private Logger logger = LoggerFactory.getLogger(getClass());

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        logger.info("登陆失败");

        response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(objectMapper.writeValueAsString(exception.getMessage()));
    }
}

SmsCodeAuthenticationSecurityConfig

package com.jjxt.b.config;

import com.jjxt.b.config.security.CustomAuthenticationFailureHandler;
import com.jjxt.b.config.security.CustomAuthenticationSuccessHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.stereotype.Component;

@Component
public class SmsCodeAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    @Autowired
    private CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;
    @Autowired
    private CustomAuthenticationFailureHandler customAuthenticationFailureHandler;


    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    public void configure(HttpSecurity http) throws Exception {

        SmsCodeAuthenticationFilter smsCodeAuthenticationFilter = new SmsCodeAuthenticationFilter();
        smsCodeAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        smsCodeAuthenticationFilter.setAuthenticationSuccessHandler(customAuthenticationSuccessHandler);
        smsCodeAuthenticationFilter.setAuthenticationFailureHandler(customAuthenticationFailureHandler);

        SmsCodeAuthenticationProvider smsCodeAuthenticationProvider = new SmsCodeAuthenticationProvider();
        smsCodeAuthenticationProvider.setUserDetailsService(userDetailsService);

        http.authenticationProvider(smsCodeAuthenticationProvider)
                .addFilterAfter(smsCodeAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

    }
}

SecurityConfiguration 配置短信验证

package com.jjxt.b.config;

import com.jjxt.b.config.security.*;
import com.jjxt.b.service.sys.UsersService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.rememberme.AbstractRememberMeServices;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import org.springframework.security.web.csrf.*;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.sql.DataSource;
import java.io.IOException;
import java.util.Arrays;
import java.util.UUID;
//import  com.jjxt.b.config.SmsCodeAuthenticationSecurityConfig;

@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    private String key;

    @Value("${feature.csrf.enabled}")
    private boolean enableCSRF;

    @Autowired
    private UsersService usersService;

    @Autowired
    private AuthenticationEntryPoint authenticationEntryPoint;

    @Autowired
    @Lazy
    private PasswordEncoder passwordEncoder;

    @Qualifier(PersistentConfiguration.DataSources.CORE)
    @Autowired
    private DataSource dataSource;

    @Autowired
    PersistentTokenRepository persistentTokenRepository;

    @Autowired
    AbstractRememberMeServices rememberMeServices;

    @Autowired
    CSRFProcessor csrfProcessor;
    @Autowired
    private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(authProvider());
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    private static final String PROJECT_URL="/api/projects/";
    private static final String BIDSECT_URL= PROJECT_URL+"bidsect/";
    private static final String USER_URL = PROJECT_URL + "users/";
    private static final String UNIT_URL = PROJECT_URL+"units/";
    private static final String CONTRACT_URL = PROJECT_URL+"contracts/";

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
            .antMatchers("/", "/api/user/login","/api/user/sms/**","/api/sms/**",
                    "/test/**").permitAll()
            .antMatchers("/api/admin/users/**").hasAnyRole("ADMIN", "SUPER_ADMIN")
                .antMatchers("/api/admin/company/all").hasAnyRole("ADMIN", "SUPER_ADMIN")
            .antMatchers("/api/admin/**").hasAnyRole( "SUPER_ADMIN")
                // can only edit by _PEDITOR.
            .antMatchers(Arrays.asList("create","update", "delete", "flowupdate").stream().map(i -> PROJECT_URL+i)
                    .toArray(String[]::new)).hasAnyRole("_PEDITOR")
            .antMatchers(Arrays.asList("create","update", "delete").stream().map(i -> BIDSECT_URL+i)
                    .toArray(String[]::new)).hasAnyRole("_PEDITOR")
            .antMatchers(Arrays.asList("add","delete").stream().map(i -> USER_URL+i)
                    .toArray(String[]::new)).hasAnyRole("_PEDITOR")
            .antMatchers(Arrays.asList("create","update", "delete").stream().map(i -> UNIT_URL+i)
                    .toArray(String[]::new)).hasAnyRole("_PEDITOR")
            .antMatchers(Arrays.asList("create","update", "delete").stream().map(i -> CONTRACT_URL+i)
                    .toArray(String[]::new)).hasAnyRole("_PEDITOR")
            // mind the order.
            .antMatchers("/api/**").authenticated()
            .and().apply(new CustomizedRememberMeConfigurer<>()).key(key)
                .rememberMeServices(rememberMeServices).tokenRepository(persistentTokenRepository)
//                 以下短信登录认证的配置
                .and()
                .apply(smsCodeAuthenticationSecurityConfig)
            .and()
            .exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())
                .authenticationEntryPoint(authenticationEntryPoint);

        if (enableCSRF) {
            http.csrf().csrfTokenRepository(csrfTokenRepository())
                .and()
                .addFilterAfter(new OncePerRequestFilter() {
                    @Override
                    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
                        csrfProcessor.process(request, response);
                        filterChain.doFilter(request, response);
                    }
                }, CsrfFilter.class);
        } else {
            http.csrf().disable();
        }
    }

    @Bean
    CSRFProcessor csrfProcessor(){
        return enableCSRF ? new DefaultCSRFProcessor() : new IgnoreCSRFProcessor();
    }

    private HttpSessionCsrfTokenRepository csrfTokenRepository() {
        HttpSessionCsrfTokenRepository repository = new HttpSessionCsrfTokenRepository();
        repository.setHeaderName("X-XSRF-TOKEN");
        return repository;
    }

    @Bean
    public CustomizedRememberMeServices rememberMeServices() {
        CustomizedRememberMeServices services =
            new CustomizedRememberMeServices(getKey(),
                usersService, persistentTokenRepository);
        return services;
    }

    String getKey() {
        if (this.key == null) {
            this.key = UUID.randomUUID().toString();
        }
        return this.key;
    }


    @Bean
    public PersistentTokenRepository persistentTokenRepository() {
        JdbcTokenRepositoryImpl db = new JdbcTokenRepositoryImpl();
        db.setDataSource(dataSource);
        return db;
    }

    @Bean
    public AuthenticationEntryPoint securityException401EntryPoint() {
        return new AuthenticationEntryPoint() {
            @Override
            public void commence(HttpServletRequest request,
                                 HttpServletResponse response,
                                 AuthenticationException authException) throws IOException, ServletException {
                response.sendError(HttpServletResponse.SC_UNAUTHORIZED,
                        authException.getMessage());
            }
        };
    }

    @Bean
    public DaoAuthenticationProvider authProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(usersService);
        authProvider.setPasswordEncoder(passwordEncoder);
        return authProvider;
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    @Override
    public UserDetailsService userDetailsService() {
        return usersService;
    }


    public static class CustomAccessDeniedHandler implements AccessDeniedHandler {

        private static final Logger LOG = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);

        @Override
        public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
            LOG.error("access denied", accessDeniedException);
        }
    }
}

代码结构逻辑

image.png

发送验证码

 @RequestMapping("/sms/code")
    @ResponseBody
    public Object sms(@RequestParam("mobile") String mobile, HttpSession session) throws ClientException {

        UserDetails u= usersService.getPhone(mobile);
        if(u!=null)
        {
            int code = (int) Math.ceil(Math.random() * 9000 + 1000);

            Map<String, Object> map = new HashMap<>(16);
            map.put("mobile", mobile);
            map.put("code", code);
            SmsUtil.sendSmsCode(mobile, code+"");
            session.setAttribute("smscode", map);
            return "1";
        }else
        {
               return "0";
        }

    }

前端vue 代码

image.png

export function plogin(data) {
  return request({
    url: '/sms/login',
    method: 'post',
    headers: {
		  "Content-Type": "multipart/form-data"
	    },
      params:data
  })
}