上一篇:https://lawsssscat.blog.csdn.net/article/details/105332569

前面的代码下载:https://github.com/LawssssCat/v-security/tree/v2.4
(涉及到个人账号,一些配置没有上传,需要自行添加)

实现邮箱认证码登录,四步

  • 电子邮件原理
  • 开发邮箱验证码接口
  • 校验邮箱验证码并登录
  • 重构代码

参考:原理+SpringBoot发送mail邮件

发送接收电子邮件原理

电子邮件服务器
用户要在Internet上提供电子邮件功能,必须有专门的 <mark>电子邮件服务器</mark>。

邮件服务器就好像是互联网世界的邮局。可以划分为两种类型:

  • SMTP 邮件服务器:用户替用户 <mark>发送</mark> 邮件外面发送给本地用户的邮件。
    (邮递员)
  • POP3/IMAP邮件服务器:用户帮助用户 <mark>读取</mark> SMTP邮件服务器接收进来的邮件。
    (门前邮递箱)

电子邮箱
而 <mark>电子邮箱(163、qq、gmail)</mark> 其实就是用户在 <mark>电子邮件服务器</mark> 上申请的账户

邮件客户端
而 <mark>邮件客户端(FoxMail、Outlook等)</mark> 集发送和收发功能于一体,帮助用户将邮件发送给 SMTP邮件服务器 和从 POP3/IMAP邮件服务器 读取用户的电子邮件。

邮件传输协议

  • 简单邮件传输协议(Simple Mail Transfer Protocol,SMTP:定义了客户端和SMTP邮件<mark>发送</mark>服务器之间,以及两台SMTP邮件服务器之间的通信规则。
  • 邮局协议(Post Office Protocol,POP3:定义了客户端和POP3邮件<mark>接收</mark>服务器的通信规则。
  • 消息访问协议 (Internet Message Access Protocol,IMAP:对POP3协议的一种扩展,也是定义了客户端和IMAP邮件服务器的通信规则。

邮件格式
邮件内容的基本格式和具体细节分别是由 RFC822 文档MIME 协议 定义的。

  • RFC822 文档
    定义的文件格式包括两个部分:邮件头、邮件体。
  • 复杂邮件体的格式(Multipurpose Internet Mail Extensions,MIME
    1. 可以表达<mark>多段</mark>平行的文本内容和<mark>非文本</mark>的邮件内容,例如,在邮件体中内嵌的图像数据和邮件附件等。
    2. MIME协议的数据格式也可以避免邮件内容在传输过程中发生信息丢失。
    3. MIME协议不是对RFC822邮件格式的升级和替代,而是基于RFC822邮件格式的扩展应用。

一言以蔽之,<mark>RFC822定义了邮件内容的格式和邮件头字段的详细细节,MIME协议则是定义了如何在邮件体部分表达出的丰富多样的数据内容</mark>。

电子邮件发送和接收流程

  1. 用户A的电子邮箱为:xx@qq.com,通过<mark>邮件客户端软件</mark>写好一封邮件,交到<mark>QQ的邮件服务器</mark>,这一步使用的<mark>协议是SMTP</mark>,对应图示的①;

  2. <mark>QQ邮箱服务器</mark> 会根据用户A发送的邮件进行解析,根据收件地址判断是否是自己管辖的账户
    如果收件地址也是 <mark>QQ邮箱服务器</mark> 内,那么会直接存放到自己的存储空间。
    如果发送给<mark>其他服务器(如163)</mark>,那么QQ邮箱就会将邮件<mark>转发</mark>到163邮箱服务器,<mark>转发使用SMTP协议</mark>,对应图示的②;

  3. 163邮箱服务器接收到QQ邮箱服务器转发过来的邮件
    <mark>会判断收件地址是否在自己内部,发现是自己的账户</mark>,那么就会将QQ邮箱转发过来的邮件存放到自己的内部存储空间,对应图示的③;

  4. 用户B会通过邮件客户端软件先向163邮箱服务器请求,要求收取自己的邮件,对应图示的④;<mark>使用的协议是POP3</mark>

  5. 163邮箱服务器收到用户B的请求后,会从自己的存储空间中取出B未收取的邮件,对应图示⑤;<mark>使用的协议是POP3</mark>

  6. 163邮箱服务器取出用户B未收取的邮件后,将邮件发给用户B,对应图示的⑥;<mark>使用的协议是POP3</mark>

开发邮箱验证码接口

邮箱服务器打开smtp服务

引入依赖

        <!--发送邮件:实现邮箱验证码-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-mail</artifactId>
        </dependency>

发送email的依赖,springboot帮我们封装好了
可以看到,底层用的sun公司的api

spring:
  mail:
    # 设置邮箱主机
    host: smtp.163.com
    # 非SSL的端口
    port: 25
    # 默认即为smtp
    protocol: smtp
    # 设置用户名
    username: xxxxxxxxxxxxxxxxxx@163.com
    # 设置密码,该处的密码是QQ邮箱开启SMTP的授权码而非登录密码
    password: xxxxxxxxxxxxxxxxxxxxxxxx
    # 默认即为utf8
    default-encoding: utf-8

编写 EmailCodeSender 实现

对springboot的sender进一步封装,通过接口能快速更换api

package cn.vshop.security.core.validate.code.email;

import cn.vshop.security.core.properties.SecurityProperties;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;

/** * 邮箱发送类的实现 * * @author alan smith * @version 1.0 * @date 2020/4/6 0:47 */
@Slf4j
@Setter
public class DefaultEmailCodeSender implements EmailCodeSender {

    @Value("${spring.mail.username}")
    private String from;

    @Autowired
    private JavaMailSender mailSender;

    @Autowired
    private SecurityProperties securityProperties;

    /** * 简单文本邮件 */
    @Override
    public void send(String to, String code) {
        SimpleMailMessage message = new SimpleMailMessage();
        // 设置邮件主题
        //message.setSubject("登录验证码");
        // 邮件的内容
        message.setText(contentBild(code, securityProperties.getCode().getEmail().getExpireIn()));
        // 设置接收者邮箱
        message.setTo(to);
        // 设置发送者邮箱
        message.setFrom(from);
        // 发送
        mailSender.send(message);
    }

    /** * 生成邮箱信息 */
    private String contentBild(String code, int expireIn) {
        StringBuilder sb = new StringBuilder();
        sb.append("您的验证码:");
        sb.append(code);
        sb.append(",有效时效:");
        sb.append(expireIn);
        return sb.toString();
    }


}

package cn.vshop.security.core.validate.code.email;

/** * 邮箱发送器接口 * * @author alan smith * @version 1.0 * @date 2020/4/6 0:42 */
public interface EmailCodeSender {
    /** * 发送验证码到目标邮箱 * * @param to 目标邮箱 * @param code 验证码 */
    void send(String to, String code);
}

编写校验码生成器 EmailCodeGenerator

package cn.vshop.security.core.validate.code.email;

import cn.vshop.security.core.properties.SecurityProperties;
import cn.vshop.security.core.validate.code.ValidateCode;
import cn.vshop.security.core.validate.code.ValidateCodeGenerator;
import org.springframework.beans.factory.annotation.Autowired;

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

/** * @author alan smith * @version 1.0 * @date 2020/4/6 11:33 */
public class EmailCodeGenerator implements ValidateCodeGenerator {

    @Autowired
    private SecurityProperties securityProperties;

    @Override
    public ValidateCode generate(HttpServletRequest request) {
        return new ValidateCode(getCode(), securityProperties.getCode().getEmail().getExpireIn());
    }

    private Random random = new Random();

    private String getCode() {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < securityProperties.getCode().getEmail().getLength(); i++) {
            sb.append(random.nextInt(10));
        }
        return sb.toString();
    }

}

修改 ValidateCodeController

在 ValidateCodeController 中添加接口,和相应的依赖
<mark>按情况修改前面代码的名称,使代码读起来更加合理</mark>

package cn.vshop.security.core.validate.code;

import cn.vshop.security.core.validate.code.email.EmailCodeSender;
import cn.vshop.security.core.validate.code.image.ImageCode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.web.bind.ServletRequestBindingException;
import org.springframework.web.bind.ServletRequestUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.ServletWebRequest;

import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/** * @author alan smith * @version 1.0 * @date 2020/4/5 1:33 */
@RestController
public class ValidateCodeController {

    /** * 图片校验码信息存入session中的key */
    public static final String IMAGE_SESSION_KEY = "SESSION_KEY_IMAGE_CODE";

    /** * 邮箱校验码信息存入session中的key */
    public static final String EMAIL_SESSION_KEY = "SESSION_KEY_EMAIL_CODE";

    /** * 注入接口,接口的实现完成【图片】验证码的生成和封装 */
    @Autowired
    @Qualifier("imageCodeGenerator")
    private ValidateCodeGenerator imageCodeGenerator;

    /** * 注入接口,接口的实现完成【邮箱】验证码的生成和封装 */
    @Autowired
    @Qualifier("emailCodeGenerator")
    private ValidateCodeGenerator emailCodeGenerator;

    /** * 发送邮箱的工具实现 */
    @Autowired
    @Qualifier("emailCodeSender")
    private EmailCodeSender emailCodeSender ;

    /** * Spring 的工具类,用以操作session */
    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    /** * 图片验证码接口 */
    @GetMapping("/code/image")
    public void createCode(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 生成随机的验证码,并封装为 ImageCode
        ImageCode imageCode = (ImageCode) imageCodeGenerator.generate(request);
        // 获取session,并把键值对存入session
        sessionStrategy.setAttribute(
                // ServletWebRequest 是一个适配器(Adapter),把servlet封装成spring的WebRequest(继承了RequestAttributes)
                // 通过把请求传进来,sessionStrategy会从请求中获取session
                new ServletWebRequest(request),
                // 存入session中的key
                IMAGE_SESSION_KEY,
                // 存入session中的值
                imageCode);
        // javax的io工具包
        // 将BufferedImage以指定格式写入输出流中
        ImageIO.write(
                // 以BufferedImage类型的图片
                imageCode.getImage(),
                // 输出的图片格式
                "JPEG",
                // 输出流,输出到响应体中
                response.getOutputStream());

    }

    /** * 邮箱验证码接口 */
    @GetMapping("/code/email")
    public void createSmsCode(HttpServletRequest request, HttpServletResponse response) throws ServletRequestBindingException {
        // 生成邮箱形式的验证码(普通的验证码)
        ValidateCode emailCode = emailCodeGenerator.generate(request);
        // 将验证码放入session
        sessionStrategy.setAttribute(new ServletWebRequest(request), EMAIL_SESSION_KEY, emailCode);
        // 请求参数中获取目标eamil
        String email = ServletRequestUtils.getRequiredStringParameter(request, "email") ;
        // 发送短信
        emailCodeSender.send(email, emailCode.getCode());
    }

}

打开接口的访问权限


修改application.yml

把登录的界面改成新写的界面

v:
  security:
    browser:
      # 登录页面
      loginPage: /login3.html

前端代码

创建新的登录页面 login3.html

前端代码bug不深究

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

<h2>邮箱登录</h2>
<form id="loginForm" method="post" action="/authentication/email">
    邮箱: <input type="text" name="email" value="1191693505@qq.com"><br>
    验证码:<input type="text" name="emailCode">
    <button onclick="sendCode(this)">发送验证码</button>
    <br>
    <input type="checkbox" value="true" name="remember-me">记住我<br>
    <input type="submit" value="登录">
</form>

<script> let form = new FormData(document.getElementById("loginForm")); function sendCode(o) { let xhr = new XMLHttpRequest(); xhr.open('GET', path()); xhr.send(null); xhr.onload = function () { alert("验证码发送成功!"); } time(o); } function path() { let email = form.get("email"); return "/code/email?email=" + email; } let wait = 60 ; let content = "发送验证码" function time(o) { if (wait == 0) { o.removeAttribute("disabled"); if (content) o.innerHTML = content; wait = 60; } else { o.setAttribute("disabled", true); if (o.innerHTML) content = o.innerHTML; o.innerHTML = wait + "秒后可以重新发送"; wait--; setTimeout(() => { time(o) }, 1000); } } </script>

</body>
</html>

访问测试
http://localhost:8080/login3.html

写入邮箱,

前面的代码下载:https://github.com/LawssssCat/v-security/tree/v2.4.1

重构代码(一)

重构思路是用模板方法把代码(分级)抽出

  • 声明 ValidateCodeProcessor 接口(处理验证码生成整个流程

    生成验证码过程抽象为三步:<mark>生成、存储、发送</mark>
    <mark>如果整个验证码逻辑发生改变,只需要借助一级创建一个新接口实现即可。</mark>

  • 接口有一个抽象的实现 AbstractValidateCodeProcessor

    实现两步:<mark>生成</mark>、<mark>存储</mark>
    其中<mark>生成逻辑</mark>封装在:ValidateCodeGenerator,根据不同类型认证码给出不同实现

  • 而不一样的部分:<mark>发送</mark>(邮箱发送、图片发送)则写到子类
    ImageCodeProcessor 和 EmailCodeProcessor

重构抽象类 generate方法时,用到 spring 常见的开发技巧:依赖查找
.
因为图形验证码和邮箱验证码的生成逻辑都是封装在 ValidateCodeGenerator接口下面,当我们(下图)形式注入时

spring会把实现了接口的bean,以名字作为key,bean的值作为value存入
当收到请求,会从请求中截取认证类型的部分,调用相应的认证类型的 ValidateCodeGenerator

前面的代码下载:https://github.com/LawssssCat/v-security/tree/v2.4.2

校验邮箱验证码并登录

仿照(下图)用户密码登录流程,给出邮箱验证码验证流程

要创建下面几个类:

  • EmailCodeFilter:拦截邮件认证请求,校验邮箱认证码是否正确
  • EmailAuthenticationFilter:拦截邮件认证请求,通过邮箱获取角色认证
  • EmailAuthenticationToken:短信认证Token,封装邮件登录信息
  • EmailAuthenticaionProvider:能对邮件认证Token处理的Provider解析成UserDetails
  • EmailUserDetailsService:根据邮箱获取UserDetails

回看:【认证】流程源码分析

因为无论是浏览器亦或者手机端均会使用短信验证,因此写在 core 模块中。

编写 EmailAuthenticationToken

直接复制 UsernamePasswordAuthenticationToken,并且去掉Credential(密码)部分即可

package cn.vshop.security.core.authentication.email;

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

import java.util.Collection;

/** * 封装登录信息 * <p> * 直接复制 {@link UsernamePasswordAuthenticationToken},并且去掉Credential(密码)部分 * * @author alan smith * @version 1.0 * @date 2020/4/6 22:26 */
public class EmailCodeAuthenticationToken extends AbstractAuthenticationToken {

    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    // ~ Instance fields
    // ================================================================================================

    /** * 登录前:邮箱 * 登录后:用户信息 */
    private final Object principal;

    // ~ Constructors
    // ===================================================================================================

    /** * This constructor can be safely used by any code that wishes to create a * <code>UsernamePasswordAuthenticationToken</code>, as the {@link #isAuthenticated()} * will return <code>false</code>. */
    public EmailCodeAuthenticationToken(Object email) {
        super(null);
        this.principal = email;
        setAuthenticated(false);
    }

    /** * This constructor should only be used by <code>AuthenticationManager</code> or * <code>AuthenticationProvider</code> implementations that are satisfied with * producing a trusted (i.e. {@link #isAuthenticated()} = <code>true</code>) * authentication token. * * @param principal * @param authorities */
    public EmailCodeAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        super.setAuthenticated(true); // must use super, as we override
    }

    // ~ Methods
    // ========================================================================================================


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

        super.setAuthenticated(false);
    }

    @Override
    public void eraseCredentials() {
        super.eraseCredentials();
    }

}

编写 EmailAuthenticationFilter

同样的,复制 UsernamePasswordAuthenticationFilter,并改成 Email 形式

package cn.vshop.security.core.authentication.email;

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.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.util.Assert;

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

/** * 拦截邮箱验证码请求,并且组装Token * <p> * 直接复制 {@link UsernamePasswordAuthenticationFilter},并作出相依修改 * * @author alan smith * @version 1.0 * @date 2020/4/6 22:46 */
public class EmailCodeAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    // ~ Static fields/initializers
    // =====================================================================================

    // 请求中携带参数的名字
    public static final String SPRING_SECURITY_FORM_EMAIL_KEY = "email";

    private String emailParameter = SPRING_SECURITY_FORM_EMAIL_KEY;
    private boolean postOnly = true;

    // ~ Constructors
    // ===================================================================================================

    public EmailCodeAuthenticationFilter() {
        super(new AntPathRequestMatcher(
                // 匹配的请求
                "/authentication/email", "POST"));
    }

    // ~ Methods
    // ========================================================================================================

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
                                                HttpServletResponse response) throws AuthenticationException {
        // 判断当前请求是否为POST请求,如果不是就抛出异常
        if (postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException(
                    "Authentication method not supported: " + request.getMethod());
        }

        String email = obtainEmail(request);

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

        email = email.trim();

        EmailCodeAuthenticationToken authRequest = new EmailCodeAuthenticationToken(email);

        // 把请求信息放入Token,比如说IP、session
        // Allow subclasses to set the "details" property
        setDetails(request, authRequest);

        // 使用AuthenticationManager进行认证流程
        return this.getAuthenticationManager().authenticate(authRequest);
    }


    /** * Enables subclasses to override the composition of the username, such as by * including additional values and a separator. * * @param request so that request attributes can be retrieved * @return the username that will be presented in the <code>Authentication</code> * request token to the <code>AuthenticationManager</code> */
    protected String obtainEmail(HttpServletRequest request) {
        return request.getParameter(emailParameter);
    }

    /** * Provided so that subclasses may configure what is put into the authentication * request's details property. * * @param request that an authentication request is being created for * @param authRequest the authentication request object that should have its details * set */
    protected void setDetails(HttpServletRequest request, EmailCodeAuthenticationToken authRequest) {
        authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
    }

    /** * Sets the parameter name which will be used to obtain the username from the login * request. * * @param emailParameter the parameter name. Defaults to "username". */
    public void setEmailParameter(String emailParameter) {
        Assert.hasText(emailParameter, "Username parameter must not be empty or null");
        this.emailParameter = emailParameter;
    }


    /** * Defines whether only HTTP POST requests will be allowed by this filter. If set to * true, and an authentication request is received which is not a POST request, an * exception will be raised immediately and authentication will not be attempted. The * <tt>unsuccessfulAuthentication()</tt> method will be called as if handling a failed * authentication. * <p> * Defaults to <tt>true</tt> but may be overridden by subclasses. */
    public void setPostOnly(boolean postOnly) {
        this.postOnly = postOnly;
    }

    public final String getEmailParameter() {
        return emailParameter;
    }

}

编写 EmailCodeAuthenticationProvider

提供对我们自定义的 EmailAuthenticationToken 的认证提供者。

package cn.vshop.security.core.authentication.email;

import org.springframework.security.authentication.AuthenticationProvider;
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;

/** * 邮箱验证码认证的提供者, * * @author alan smith * @version 1.0 * @date 2020/4/6 23:05 */
public class EmailCodeAuthenticationProvider implements AuthenticationProvider {

    private UserDetailsService userDetailsService ;

    /** * 认证的主要逻辑 * * @param authentication 我们自定义的 EmailCodeAuthenticationToken * @return * @throws AuthenticationException */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        // supports方法通过已经说明token为EmailCodeAuthenticationToken,因此可以强转
        EmailCodeAuthenticationToken authenticationToken = (EmailCodeAuthenticationToken) authentication;
        // 此时principal为email,调用(自定义)UserDetailsService,通过email获取UserDetails
        UserDetails user = userDetailsService.loadUserByUsername((String) authenticationToken.getPrincipal());

        if(user==null){
            // 如果查找不到数据,抛出内部服务异常
            // 这个InternalAuthenticationServiceException异常将被视为可处理异常,不会被最终抛出
            throw new InternalAuthenticationServiceException("无法获取用户信息") ;
        }

        // 重新生成(已认证)Token
        EmailCodeAuthenticationToken authenticationResult = new EmailCodeAuthenticationToken(user, user.getAuthorities());
        // 将(未认证)Token中的IP、session等信息放入(已认证)Token中
        authenticationResult.setDetails(authenticationToken.getDetails());

        return authenticationResult;
    }

    /** * 判断是否当前认证请求是否是EmailCodeAuthenticationToken * * @param authentication 当前的请求Token * @return 如果是EmailCodeAuthenticationToken或其子类,则返回true,表示支持当前认证 */
    @Override
    public boolean supports(Class<?> authentication) {
        return EmailCodeAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

编写 EmailCodeFilter

拦截邮件认证请求,校验邮箱认证码是否正确

参考之前写的 ImageCodeFilter

package cn.vshop.security.core.validate.code.email;

import cn.vshop.security.core.properties.SecurityProperties;
import cn.vshop.security.core.validate.code.ValidateCode;
import cn.vshop.security.core.validate.code.ValidateCodeException;
import cn.vshop.security.core.validate.code.ValidateCodeProcessor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.social.connect.web.HttpSessionSessionStrategy;
import org.springframework.social.connect.web.SessionStrategy;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.bind.ServletRequestBindingException;
import org.springframework.web.bind.ServletRequestUtils;
import org.springframework.web.context.request.ServletWebRequest;
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 java.io.IOException;
import java.util.HashSet;
import java.util.Set;

/** * 拦截邮箱认证请求,校验邮箱认证码是否正确 * <p> * 每次请求只拦截一次 OncePerRequestFilter * 需要初始化 InitializingBean * * @author alan smith * @version 1.0 * @date 2020/4/7 0:51 */
@Slf4j
@Setter
public class EmailCodeFilter extends OncePerRequestFilter implements InitializingBean {

    /** * spring的工具类,用来匹配Ant风格路径,如:“/user/*” */
    private AntPathMatcher pathMatcher = new AntPathMatcher();

    /** * 操作session的工具类 */
    private SessionStrategy sessionStrategy = new HttpSessionSessionStrategy();

    /** * 需要拦截的URL * (默认添加 /authentication/email) */
    private Set<String> urls = new HashSet<>();

    /** * (需要手动注入) */
    private SecurityProperties securityProperties;

    /** * 如果在Spring环境中,会在配置加载后执行 * (这里需要手动执行) * * @throws ServletException */
    @Override
    public void afterPropertiesSet() throws ServletException {
        super.afterPropertiesSet();
        String[] configUrls = securityProperties.getCode().getEmail().getUrls();
        for (String url : configUrls) {
            urls.add(url);
        }
        urls.add("/authentication/email");

    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 循环判断是否执行过滤
        boolean action = false;
        for (String url : urls) {
            if (pathMatcher.match(url, request.getRequestURI())) {
                action = true;
                break;
            }
        }

        // 如果是邮箱校验请求,执行邮箱校验逻辑
        if (action) {
            // 尝试校验
            validate(new ServletWebRequest(request, response));
        }

        // 校验通过or不是email校验请求
        filterChain.doFilter(request, response);
    }

    /** * 邮箱校验码存储在session中对应的key */
    private final static String SESSION_KEY_EMAIL = ValidateCodeProcessor.SESSION_KEY_PREFIX + "EMAIL";

    /** * 校验的逻辑,emailCode * * @param request */
    private void validate(ServletWebRequest request) throws ServletRequestBindingException {
        // 从session中获取封装好的ValidateCode
        ValidateCode codeInSession = (ValidateCode) sessionStrategy.getAttribute(request, SESSION_KEY_EMAIL);
        // 从request中获取请求参数ValidateCode
        String codeInRequest = ServletRequestUtils.getStringParameter(request.getRequest(), "emailCode");
        if (StringUtils.isBlank(codeInRequest)) {
            throw new ValidateCodeException("验证码的值不能为空");
        }
        if (codeInSession == null) {
            throw new ValidateCodeException("验证码不存在");
        }
        if (codeInSession.isExpired()) {
            sessionStrategy.removeAttribute(request, SESSION_KEY_EMAIL);
            throw new ValidateCodeException("验证码已过期");
        }
        if (!StringUtils.equalsIgnoreCase(codeInSession.getCode(), codeInRequest)) {
            throw new ValidateCodeException("验证码不匹配");
        }
        sessionStrategy.removeAttribute(request, SESSION_KEY_EMAIL);
    }
}

编写配置类 EmailCodeAuthenticationSecurityConfig
专门进行 EmailCode(邮箱验证码)的配置

package cn.vshop.security.core.authentication.email;

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.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.stereotype.Component;

/** * 关于短信验证码的配置 * <p> * (因为既要在浏览器中用,也要在app中用,因此写在core内) * * 写好后只需在应用配置(如BrowserSecurityConfig)中导入配置即可生效 * * @author alan smith * @version 1.0 * @date 2020/4/7 1:47 */
@Component
public class EmailCodeAuthenticationSecurityConfig
        // HttpSecurity关于DefaultSecurityFilterChain的配置适配器
        extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {

    @Autowired
    private AuthenticationSuccessHandler authenticationSuccessHandler;
    @Autowired
    private AuthenticationFailureHandler authenticationFailureHandler;
    @Autowired
    private UserDetailsService userDetailsService;

    /** * 对FilterChain的配置 * 同{@link WebSecurityConfigurerAdapter}的configure(HttpSecurity http) * * @param http 封装http的请求响应,可以操作FilterChain * @throws Exception */
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
                // 将我们自定义的provider添加到AuthenticationManager管理的provider集合内
                .authenticationProvider(emailCodeAuthenticationProvider())
                // 将我们自定义的filter添加到UsernamePasswordAuthenticationFilter的后面
                // 为什么是后面?
                // 因为其他配置均已UsernamePasswordAuthenticationFilter为基准,把校验码的校验如EmailCodeFilter配置在其之前,
                // 对应的这类的认证过滤器就应配置在其之后
                .addFilterAfter(getEmailCodeAuthenticationFilter(http), UsernamePasswordAuthenticationFilter.class);
    }

    /** * 构造邮箱验证码校验过滤器 * * @param http */
    private EmailCodeAuthenticationFilter getEmailCodeAuthenticationFilter(HttpSecurity http) {
        // 创建邮箱验证码校验过滤器
        EmailCodeAuthenticationFilter filter = new EmailCodeAuthenticationFilter();
        // 设置认证管理器
        filter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        // 注册successHandler
        filter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
        // 注册failureHandler
        filter.setAuthenticationFailureHandler(authenticationFailureHandler);
        return filter;
    }

    /** * 构造邮箱验证码的provider * * @return */
    private EmailCodeAuthenticationProvider emailCodeAuthenticationProvider() {
        EmailCodeAuthenticationProvider provider = new EmailCodeAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        return provider;
    }


}

修改 BrowserSecurityConfig

在应用配置中 导入新加的配置

登录测试

访问:http://localhost:8080/login4.html

把两种登录方式放入了同一页面login4.html

不输入验证码,直接邮箱登录

随便乱输入

输入正确验证码

等待60s(验证码过期)

同时,图片验证码也是可用的

前面的代码下载:https://github.com/LawssssCat/v-security/tree/v2.4.3

重构代码(二)

图形验证码和邮箱验证码重复的配置部分
将重复部分抽出为一个个的类,用apply方法应用其配置,使其生效

图形和短信校验码的校验过滤器(validateFilter)合并为一个。


前面的代码下载:https://github.com/LawssssCat/v-security/tree/v3.0