likes
comments
collection
share

在 Spring Security 中定义多种登录方式,比如邮箱、手机验证码登录

作者站长头像
站长
· 阅读数 14

本文正在参加「金石计划」

日积月累,水滴石穿 😄

前言

现在各大网站登录的方式是越来越多。比如:传统的用户名密码登录、快捷的邮箱、手机验证码登录,还有流行的第三方登录。那本篇呢,就给大家带来如何在 Spring Security 中定义使用邮箱验证码登录方式。看完本篇,让你学会自定义认证方式,如果公司还需要使用手机验证码登录,简简单单就能集成,毕竟流程是一致的。

编码

自定义验证方式需要使用到 Spring Security 内置的几个对象,如果各位还不了解,可以先看看这篇文章:Spring Security 中重要对象汇总

用户名密码表单登录会进入到 UsernamePasswordAuthenticationFilter。在这整个类中,还会用到一个对象 UsernamePasswordAuthenticationToken

  • AbstractAuthenticationProcessingFilter
  • AbstractAuthenticationToken

EmailVerificationCodeAuthenticationFilter

参考 UsernamePasswordAuthenticationFilter(copy)类并进行删减, 定义 EmailVerificationCodeAuthenticationFilter 类,内容如下:

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
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.StringUtils;

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

/**
 * @author cxyxj
 */
public class EmailVerificationCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    /**
     * 默认的请求参数名称
     */
    public static final String EMAIL_KEY = "email";
    public static final String EMAIL_CODE_KEY = "emailCode";

    private String emailParameter = EMAIL_KEY;
    private String emailCodeParameter = EMAIL_CODE_KEY;
    /**
     * 是否仅支持post方式
     */
    private boolean postOnly = true;


    /**
     * 对请求进行过滤,只有接口为 /emil-login,请求方式为 POST,才会进入逻辑
     */
    public EmailVerificationCodeAuthenticationFilter() {
        super(new AntPathRequestMatcher("/email-login", "POST"));
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        // 需要是 POST 请求
        if (postOnly &&  !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException(
                    "Authentication method not supported: " + request.getMethod());
        }
        // 判断请求格式是否 JSON
        if (request.getContentType().equals(MediaType.APPLICATION_JSON_VALUE)) {
            Map<String, String> loginData = new HashMap<>(2);
            try {
                loginData = new ObjectMapper().readValue(request.getInputStream(), Map.class);
            } catch (IOException e) {
                throw new InternalAuthenticationServiceException("请求参数异常");
            }
            // 获得请求参数
            String email = loginData.get(emailParameter);
            String emailCode = loginData.get(emailCodeParameter);
            // 检查验证码
            checkEmailCode(emailCode);
            if(StringUtils.isEmpty(email)){
                throw new AuthenticationServiceException("邮箱不能为空");
            }
            /**
             * 使用请求参数传递的邮箱和验证码,封装为一个未认证 EmailVerificationCodeAuthenticationToken 身份认证对象,
             * 然后将该对象交给 AuthenticationManager 进行认证
             */
            EmailVerificationCodeAuthenticationToken authRequest = new EmailVerificationCodeAuthenticationToken(email);
            setDetails(request, authRequest);
            return this.getAuthenticationManager().authenticate(authRequest);
        }
        return null;
    }

    public void setDetails(HttpServletRequest request , EmailVerificationCodeAuthenticationToken token){
        token.setDetails(this.authenticationDetailsSource.buildDetails(request));
    }


    private void checkEmailCode(String emailCode) {
        // 实际当中请从 Redis 中获取
        String verifyCode = "123456";
        if(StringUtils.isEmpty(verifyCode)){
            throw new AuthenticationServiceException("请重新申请验证码!");
        }
        if (!verifyCode.equalsIgnoreCase(emailCode)) {
            throw new AuthenticationServiceException("验证码错误!");
        }
    }
}

简单理理上述代码:如下:

  • 提供默认的请求参数名称。
  • 提供无参构造方法,对请求进行过滤。
  • 继承 AbstractAuthenticationProcessingFilter,重写了 attemptAuthentication 方法,对其参数进行校验,最后调用认证管理器的 authenticate 方法。
  • setDetails 方法设置该次请求信息,比如:调用地址、sessionId。

EmailVerificationCodeAuthenticationToken

参考 UsernamePasswordAuthenticationToken(copy) 类并进行删减,定义 EmailVerificationCodeAuthenticationToken 类,内容如下:

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

import java.util.Collection;

/**
 * @author cxyxj
 */
public class EmailVerificationCodeAuthenticationToken  extends AbstractAuthenticationToken {

    private final Object principal;

    public EmailVerificationCodeAuthenticationToken(Object principal) {
        super(null);
        this.principal = principal;
        setAuthenticated(false);
    }

    public EmailVerificationCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        super.setAuthenticated(true);
    }


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

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

    @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");
        } else {
            super.setAuthenticated(false);
        }
    }
}

可以发现类中并没有 credentials 属性,因为验证码是有时效性的,存着没意义,那 eraseCredentials 方法也没必要重写了,凭证都没有,何需擦除呢! 类中还有两个构造方法,一个是设置是否已认证为 false。另外一个设置是否已认证为 true。

AuthenticationProvider

对于 Provider,我们直接看 AbstractUserDetailsAuthenticationProvider,这是来处理用户名密码认证流程的。AbstractUserDetailsAuthenticationProvider 实现了 AuthenticationProvider 类, 所以重点看看该类的重写的方法。

  • supports:验证传入的身份验证对象是否是 UsernamePasswordAuthenticationToken。如果是则返回 true。
public boolean supports(Class<?> authentication) {
   return (UsernamePasswordAuthenticationToken.class
         .isAssignableFrom(authentication));
}
  • authenticate
public Authentication authenticate(Authentication authentication)
		throws AuthenticationException {
	Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
			() -> messages.getMessage(
					"AbstractUserDetailsAuthenticationProvider.onlySupports",
					"Only UsernamePasswordAuthenticationToken is supported"));

	//获取用户名
	String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
			: authentication.getName();

	boolean cacheWasUsed = true;
	// 根据用户名从缓存中获得UserDetails对象
	UserDetails user = this.userCache.getUserFromCache(username);

	if (user == null) {
		cacheWasUsed = false;

		try {
		// 如果缓存中没有信息,通过子类 DaoAuthenticationProvider 实现的 retrieveUser 方法,返回一个 UserDetails 对象
			user = retrieveUser(username,
					(UsernamePasswordAuthenticationToken) authentication);
		}
		catch (UsernameNotFoundException notFound) {
			logger.debug("User '" + username + "' not found");

			if (hideUserNotFoundExceptions) {
				throw new BadCredentialsException(messages.getMessage(
						"AbstractUserDetailsAuthenticationProvider.badCredentials",
						"Bad credentials"));
			}
			else {
				throw notFound;
			}
		}

		Assert.notNull(user,
				"retrieveUser returned null - a violation of the interface contract");
	}

	try {
		// 检查该用户对象的各种状态,比如:账户是否未锁定、账户是否启用、账户是否未过期
		preAuthenticationChecks.check(user);
		// 使用子类 DaoAuthenticationProvider 实现的 additionalAuthenticationChecks方法,检查密码是否输入正确
		additionalAuthenticationChecks(user,
				(UsernamePasswordAuthenticationToken) authentication);
	}
	catch (AuthenticationException exception) {
		if (cacheWasUsed) {
			// There was a problem, so try again after checking
			// we're using latest data (i.e. not from the cache)
			cacheWasUsed = false;
			user = retrieveUser(username,
					(UsernamePasswordAuthenticationToken) authentication);
			preAuthenticationChecks.check(user);
			additionalAuthenticationChecks(user,
					(UsernamePasswordAuthenticationToken) authentication);
		}
		else {
			throw exception;
		}
	}
	// 检查该用户对象的各种状态,比如:凭证(密码)是否未过期
	postAuthenticationChecks.check(user);
	
	// 存入缓存
	if (!cacheWasUsed) {
		this.userCache.putUserInCache(user);
	}

	Object principalToReturn = user;

	if (forcePrincipalAsString) {
		principalToReturn = user.getUsername();
	}

	 // 会调用子类方法,设置是否已认证为true,并设置权限信息
	return createSuccessAuthentication(principalToReturn, authentication, user);
}

通过对 authenticate 方法的梳理,我们知道还有两个方法在其子类中进行实现,实现类为 DaoAuthenticationProvider

// 检查密码是否输入正确
protected void additionalAuthenticationChecks(UserDetails userDetails,
		UsernamePasswordAuthenticationToken authentication)
		throws AuthenticationException {
	if (authentication.getCredentials() == null) {
		logger.debug("Authentication failed: no credentials provided");

		throw new BadCredentialsException(messages.getMessage(
				"AbstractUserDetailsAuthenticationProvider.badCredentials",
				"Bad credentials"));
	}

	String presentedPassword = authentication.getCredentials().toString();

	if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
		logger.debug("Authentication failed: password does not match stored value");

		throw new BadCredentialsException(messages.getMessage(
				"AbstractUserDetailsAuthenticationProvider.badCredentials",
				"Bad credentials"));
	}
}

// 通过调用 UserDetailsService 的 loadUserByUsername 方法加载用户信息
protected final UserDetails retrieveUser(String username,
		UsernamePasswordAuthenticationToken authentication)
		throws AuthenticationException {
	prepareTimingAttackProtection();
	try {
		UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
		if (loadedUser == null) {
			throw new InternalAuthenticationServiceException(
					"UserDetailsService returned null, which is an interface contract violation");
		}
		return loadedUser;
	}
	catch (UsernameNotFoundException ex) {
		mitigateAgainstTimingAttack(authentication);
		throw ex;
	}
	catch (InternalAuthenticationServiceException ex) {
		throw ex;
	}
	catch (Exception ex) {
		throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
	}
}

OK,接下来就是 copy 阶段。我们就没必要学 Security 定义一个抽象类、子类,直接定义一个子类即可。EmailVerificationCodeAuthenticationProvider 实现 AuthenticationProvider

/**
 * 邮箱身份验证提供者
 * @author cxyxj
 */
public class EmailVerificationCodeAuthenticationProvider implements AuthenticationProvider {

    private EmailVerificationCodeService service;

    public EmailVerificationCodeAuthenticationProvider(EmailVerificationCodeService service) {
        this.service = service;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String email = authentication.getName();
        // 根据邮箱加载用户信息
        UserDetails userDetails = service.loadUserByEmail(email);
        if (userDetails == null) {
            // 可以自定义异常类型
            throw new InternalAuthenticationServiceException("邮箱方式登录异常");
        }
        EmailVerificationCodeAuthenticationToken result = new EmailVerificationCodeAuthenticationToken(userDetails,
                userDetails.getAuthorities());
        result.setDetails(authentication.getDetails());
        return result;
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return (EmailVerificationCodeAuthenticationToken.class
                .isAssignableFrom(authentication));
    }

}

再提供一个用来根据邮箱查询用户的服务。

@Service
public class EmailVerificationCodeService {


@Autowired
private SysUserMapper sysUserMapper;

@Autowired
private SysRoleMapper sysRoleMapper;

UserDetails loadUserByEmail(String email) {

    SysUser user = sysUserMapper.selectOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getEmail, email));
    if(Objects.isNull(user)){
        return null;
    }
    // 获得用户角色信息
    List<String> roles = sysRoleMapper.selectByRoleId(user.getRoleId());
    // 构建 SimpleGrantedAuthority 对象
    List<SimpleGrantedAuthority> authorities = roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
    return new SysUserDetails(user, authorities);
}
}

数据库脚本已经放置到项目中,位于 resources/sql 下。

配置

接下来就是将上述的重写的类,配置到 Spring Security 中。

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    MyAuthenticationSuccessHandler myAuthenticationSuccessHandler;

    @Autowired
    MyAuthenticationFailureHandler myAuthenticationFailureHandler;

    @Autowired
    private EmailVerificationCodeService emailVerificationCodeService;

    @Bean
    public EmailVerificationCodeAuthenticationFilter emailVerificationCodeAuthenticationFilter() throws Exception {
        EmailVerificationCodeAuthenticationFilter emailVerificationCodeAuthenticationFilter = new EmailVerificationCodeAuthenticationFilter();
        // 手动设置AuthenticationManager,解决authenticationManager must be specified 启动异常
        emailVerificationCodeAuthenticationFilter.setAuthenticationManager(authenticationManagerBean());
        emailVerificationCodeAuthenticationFilter.setAuthenticationSuccessHandler(new MyAuthenticationSuccessHandler());
        emailVerificationCodeAuthenticationFilter.setAuthenticationFailureHandler(new MyAuthenticationFailureHandler());
        return emailVerificationCodeAuthenticationFilter;
    }

    @Bean
    EmailVerificationCodeAuthenticationProvider emailVerificationCodeAuthenticationProvider() {
        EmailVerificationCodeAuthenticationProvider provider = new EmailVerificationCodeAuthenticationProvider(emailVerificationCodeService);
        return provider;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 这行需要加上,否则请求参数需要带上 _csrf
        http.cors().and().csrf().disable();

        http.authorizeRequests()  //开启配置
                // 需要放行的接口路径
                .antMatchers().permitAll()
                .anyRequest() //其他请求
                .authenticated(); //验证   表示其他请求需要登录才能访问


       // 将邮箱身份验证过滤器放置在 UsernamePasswordAuthenticationFilter 之后
       http.addFilterBefore(emailVerificationCodeAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
       
    // 将自定义的 Provider 添加到 authenticationProviders 属性中
 auth.authenticationProvider(emailVerificationCodeAuthenticationProvider());
    }

}

注意:在调用 addFilterBefore、addFilterAt 时,不能随意的在过滤器前后添加,只能在有排序的过滤器前后添加,否则会出现 Cannot register after unregistered Filter 异常。

已有排序的过滤器可以查看 FilterComparator 类。

在 Spring Security 中定义多种登录方式,比如邮箱、手机验证码登录

接下来就可以来测试一下,我们自定义的方式是否生效了。 在请求工具中请求接口:

POST
http://localhost:8080/email-login

{
    "email":"1990848@163.com",
    "emailCode":"123456"
}

在 Spring Security 中定义多种登录方式,比如邮箱、手机验证码登录

如果小伙伴想断点调试,可以先在 FilterChainProxy 类的 doFilterInternal方法打上一个断点,可以查看 Spring Security 过滤器链中的过滤器。会按照其顺序一个一个执行。 在 Spring Security 中定义多种登录方式,比如邮箱、手机验证码登录

因为 EmailVerificationCodeAuthenticationFilter 继承了 AbstractAuthenticationProcessingFilter,所以会先执行父类逻辑。 在 Spring Security 中定义多种登录方式,比如邮箱、手机验证码登录 确定请求路径没有错,就会进入对应的Filter逻辑。

在 Spring Security 中定义多种登录方式,比如邮箱、手机验证码登录EmailVerificationCodeAuthenticationFilter 类的 attemptAuthentication 方法最后一行会调用 this.getAuthenticationManager().authenticate(authRequest)。 执行 ProviderManager 类的 authenticate 方法。

在 Spring Security 中定义多种登录方式,比如邮箱、手机验证码登录 可以看到现在 getProviders 中有两个对象,一个是 AnonymousAuthenticationProvider,另外一个就是我们自定义的 EmailVerificationCodeAuthenticationProvider。 循环每个 AuthenticationProvider 对象,调用其 supports 方法进行验证,验证成功,调用其 authenticate 方法,进入到对应的 AuthenticationProvider 实现类。

EmailVerificationCodeAuthenticationProvider

在 Spring Security 中定义多种登录方式,比如邮箱、手机验证码登录 进入到我们自定义的 Provider 类中,调用 EmailVerificationCodeServiceloadUserByEmail方法。

总结

自定义认证方式总体来说还是很简单的;需要从头重写的也就三个类,然后就是将其配置到Spring Security 中。如果需要再自定义手机号验证码登录,按照上述流程再走一遍就行。其实也是 copy 一份,然后进行小幅度的修改即可。 在 Spring Security 中定义多种登录方式,比如邮箱、手机验证码登录

开启表单用户名密码登录

如果还需要开启用户名密码登录(JSON形式自行改造吧),需要加上如下配置:

@Bean
PasswordEncoder passwordEncoder() {
    return NoOpPasswordEncoder.getInstance();
}

@Bean
@Override
protected UserDetailsService userDetailsService() {
    InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
    manager.createUser(User.withUsername("cxyxj").password("123").roles("admin").build());
    manager.createUser(User.withUsername("security").password("security").roles("user").build());
    return manager;
}

@Override
protected void configure(HttpSecurity http) throws Exception {
    // 这行需要加上,否则请求参数需要带上 _csrf
    http.cors().and().csrf().disable();
    // 开启表单配置
    http.formLogin().permitAll();
    http.authorizeRequests()  //开启配置
            // 需要放行的接口路径
            .antMatchers().permitAll()
            .anyRequest() //其他请求
            .authenticated(); //验证   表示其他请求需要登录才能访问

   // 将邮箱身份验证过滤器放置在 UsernamePasswordAuthenticationFilter 之后
   http.addFilterAfter(emailVerificationCodeAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
   // 设置 UserDetailsService以及密码匹配器
 auth.userDetailsService(userDetailsService()).passwordEncoder(passwordEncoder());
   
  // 将自定义的 Provider 添加到 authenticationProviders 属性中
 auth.authenticationProvider(emailVerificationCodeAuthenticationProvider());
}

验证

在 Spring Security 中定义多种登录方式,比如邮箱、手机验证码登录


  • 如你对本文有疑问或本文有错误之处,欢迎评论留言指出。如觉得本文对你有所帮助,欢迎点赞 + 收藏。