java实现绑定邮箱与验证系统

最近在写的一个网络安全管控项目需要用户在账号中绑定自己的邮箱, 以便在发现用户账户存在异常之后, 及时通过邮件方式通知用户和管理员.

在这里总结一下基于SpringBoot框架的设计与实现过程.

想法

跟我们以前在其他平台注册账号绑定邮箱一样, 对于绑定邮箱, 我们需要确认用户对所绑定邮箱的所有权, 在用户进行绑定操作后, 系统会发送一封带有验证链接的邮件给对应的邮箱, 用户登录邮件点击链接进行验证.

链接问题

对于链接, 其中应该放置所有的能够用来判断用户与邮箱对应关系以及链接有效性的信息, 而且这些信息应该加密成一个密文(这里我们就叫它secret吧, 下同)作为参数放在链接中.

至于验证链接的有效性, 我们可以在生成secret之前获取当前时间信息作为参考基准加入到secret中, 这样系统接收secret到后解密, 拿到作为参考基准的时间与当前时间进行比较, 超过规定的有效期限则视为失效链接.

加密解密算法

那么加密很重要了.

这里我采用的是AES的加密算法, 该算法是对称加密算法(因为此处不是使用非对称加密的场景, 且使用对称加密可以获取较快的加密解密速度), 而在对称加密算法中AES是非常好的选择, 不仅速度快, 且相对也更安全.

验证流程

所以流程就应该是用户在绑定邮箱的时候, 提交需要绑定的邮箱, 然后系统接收到请求之后将用户账号、要绑定的邮箱以及当前时间加密为secret, 作为参数加到的后面, 将该链接放到邮件中发给用户要绑定的邮箱, 用户接收到验证邮件后, 点击里面的链接, 也就是请求了验证邮箱有效性的API, 系统接受到请求后提取其中的secret参数, 解密获取到用户账号、要绑定的邮箱以及作为参考基准的时间, 先将作为参考基准的时间与当前时间进行比较, 在规定的有效期限内则进行绑定, 将邮箱与用户账号相对应保存到数据库, 至此完成绑定.

实现

这里详述一下代码实现 👇

配置文件

项目配置文件application.yml中必要的部分配置 👇

server:
    ip: host ip/domain    #所部署服务器的地址(ip或域名)
    port: port                    #后端服务所占端口

# 配置系统所用邮箱账号
email:
    username: name@***.com      #所使用的邮箱地址
    password: password          #邮箱地址的客户端授权密码,区别于邮箱密码,一般在开启SMTP服务时要求设置
    protocol: smtp              #所用发件协议,默认为smtp
    host: host addr             #所用邮箱的服务提供商的服务器地址,如网易163邮箱的是 smtp.163.com
    port: 465                   #所用邮箱的服务提供商的服务器对应的发件协议服务的端口,如smtp协议建议使用465端口,基于ssl更安全
                                #关于使用465端口的问题, 还有个原因就是很多云服务器提供商会封闭25端口, 后面会有提及

发送邮件的功能实现

发送邮件的工具类EmailUtil 👇

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.mail.*;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import java.util.Date;
import java.util.Properties;

@Service
public class EmailUtil {

    private static String HOST;
    private static String PROTOCOL;
    private static int PORT;
    private static String EMAILADDRESS;
    private static String PASSWORD;

    private static Session getSession() {
        Properties props = new Properties();
        props.put("mail.smtp.host", HOST);//设置服务器地址
        props.put("mail.store.protocol", PROTOCOL);//设置协议
        props.put("mail.smtp.port", PORT);//设置端口
        props.put("mail.smtp.auth", "true");
        props.setProperty("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");
        props.setProperty("mail.smtp.socketFactory.fallback", "false");
        props.setProperty("mail.smtp.socketFactory.port", String.valueOf(PORT));

        Authenticator authenticator = new Authenticator() {
            @Override
            protected PasswordAuthentication getPasswordAuthentication() {
                return new PasswordAuthentication(EMAILADDRESS, PASSWORD);
            }
        };
        return Session.getDefaultInstance(props, authenticator);
    }

    public static boolean send(String toEmail, String content) {
        Session session = getSession();
        try {
            Message msg = new MimeMessage(session);
            msg.setFrom(new InternetAddress(EMAILADDRESS));
            InternetAddress[] address = {new InternetAddress(toEmail)};
            msg.setRecipients(Message.RecipientType.TO, address);
            msg.setSubject("[来自eduroam安全管控系统的邮件]");
            msg.setSentDate(new Date());
            msg.setContent(content, "text/html;charset=utf-8");
            msg.saveChanges();
            Transport.send(msg);
            return true;
        } catch (MessagingException mex) {
            mex.printStackTrace();
        }
        return false;
    }

    @Value("${email.host}")
    public void setHOST(String HOST) {
        EmailUtil.HOST = HOST;
    }

    @Value("${email.protocol}")
    public void setPROTOCOL(String PROTOCOL) {
        EmailUtil.PROTOCOL = PROTOCOL;
    }

    @Value("${email.port}")
    public void setPORT(int PORT) {
        EmailUtil.PORT = PORT;
    }

    @Value("${email.username}")
    public void setEMAILADDRESS(String EMAILADDRESS) {
        EmailUtil.EMAILADDRESS = EMAILADDRESS;
    }

    @Value("${email.password}")
    public void setPASSWORD(String PASSWORD) {
        EmailUtil.PASSWORD = PASSWORD;
    }
}

AES加密功能

利用AES对称加密算法对数据进行加密与解密的工具类AESCrypt 👇

import sun.misc.BASE64Decoder;
import sun.misc.BASE64Encoder;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import java.security.Key;
import java.security.SecureRandom;

/**
 * AES加密解密工具类
 * 利用AES对称加密算法对数据进行加密与解密
 *
 * Created by jay on 2018/08/23
 */

public class AESCrypt {
    private static String defaultKeySeed = "this is a test"; //设置默认的加密密钥, 根据自己需要设置

    public static String encrypt(String plainText) {
        Key secretKey = getKey(null);
        try {
            Cipher cipher = Cipher.getInstance("AES");
            cipher.init(Cipher.ENCRYPT_MODE, secretKey);
            byte[] plain = plainText.getBytes("UTF-8");
            byte[] result = cipher.doFinal(plain);
            BASE64Encoder encoder = new BASE64Encoder();
            return encoder.encode(result);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    public static String decrypt(String cipherText) {
        Key secretKey = getKey(null);
        try {
            Cipher cipher = Cipher.getInstance("AES");
            cipher.init(Cipher.DECRYPT_MODE, secretKey);
            BASE64Decoder decoder = new BASE64Decoder();
            byte[] encrypted = decoder.decodeBuffer(cipherText);
            byte[] result = cipher.doFinal(encrypted);
            return new String(result, "UTF-8");
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    private static Key getKey(String keySeed) {
        if (keySeed == null) {
            keySeed = System.getenv("AES_SYS_KEY");
        }
        if (keySeed == null) {
            keySeed = System.getProperty("AES_SYS_KEY");
        }
        if (keySeed == null || keySeed.trim().length() == 0) {
            keySeed = defaultKeySeed;
        }
        try {
            SecureRandom secureRandom = SecureRandom.getInstance("SHA1PRNG");
            secureRandom.setSeed(keySeed.getBytes());
            KeyGenerator generator = KeyGenerator.getInstance("AES");
            generator.init(secureRandom);
            return generator.generateKey();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

Controller的业务逻辑代码

import com.alibaba.fastjson.JSON;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiImplicitParams;
import io.swagger.annotations.ApiOperation;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

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

@RestController
@RequestMapping("UserController")
public class UserController {
    @Autowired
    private HttpServletRequest request;
    @Autowired
    private UserService userService;
    @Autowired
    private GetUserIdFromRequest getUserIdFromRequest;    
    //由于本项目使用了JWT, GetUserIdFromRequest类是用来从请求中提取token后从token获取用户id的.

    private final Log logger = LogFactory.getLog(this.getClass());

    @Value("${server.ip}" + ":" + "${server.port}")
    private String target;

    @ApiOperation(value = "用于所有用户绑定邮箱")
    @ApiImplicitParams({
            @ApiImplicitParam(paramType = "query", name = "emailAddress", value = "邮箱地址", required = true, dataType = "String"),
    })
    @PostMapping("/BindEmail")
    public Object bindEmail(String emailAddress) {
        if (userService.findFirstByEmailAddress(emailAddress) != null)
            return new ResponseMessage(-1, "操作失败! 该邮箱已被绑定!");
        String userId = getUserIdFromRequest.getUserId(request);
        long generateDate = new Date().getTime();
        Map<String, Object> map = new HashMap<>();
        map.put("userId", userId);
        map.put("emailAddress", emailAddress);
        map.put("generateDate", generateDate);
        String json = JSON.toJSONString(map);
        String secret = AESCrypt.encrypt(json);
        System.out.println("email secret: " + secret);
        String url = "http://" + target + "/UserController/VerifyEmail?secret=" + secret;
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append("您好,</br>").
                append("这是eduroam安全管控系统里绑定邮箱的验证邮件。</br>").
                append("若要最终验证您的电子邮箱地址, 请点击以下链接验证邮箱,48小时内有效,请尽快验证。</br>").
                append("<a href=\"").
                append(url).
                append("\">").
                append(url).
                append("</a></br>").
                append("如果单击链接无效,您可以将此链接复制到浏览器窗口,或在浏览器窗口中直接输入。");
        if (EmailUtil.send(emailAddress, stringBuffer.toString())) {
            logger.info("已发送用来绑定邮箱的验证邮件到" + emailAddress);
            return new ResponseMessage(0, "系统已发送验证邮件,请去邮箱查看并进行验证");
        } else
            return new ResponseMessage(-1, "系统发送邮件失败,请稍后再试");
    }

    @ApiOperation(value = "用于验证用户的绑定邮箱")
    @ApiImplicitParams({
            @ApiImplicitParam(paramType = "query", name = "emailAddress", value = "邮箱地址", required = true, dataType = "String"),
    })
    @GetMapping("/VerifyEmail")
    public Object verifyEmail(String secret) {
        secret = secret.replace(' ', '+');
        try {
            String json = AESCrypt.decrypt(secret);
            Map data = JSON.parseObject(json);
            String userId = data.get("userId").toString();
            long generateDate = (long) data.get("generateDate");
            String emailAddress = data.get("emailAddress").toString();
            long nowTime = new Date().getTime();
            if ((nowTime - generateDate) / 1000 / 60 / 60 <= 48) {
                User user = userService.findFirstByUserId(userId);
                if (user != null) {
                    user.setEmailAddress(emailAddress);
                    userService.update(user);
                    logger.info(userId + "已绑定邮箱" + emailAddress);
                    return "<h1>😄邮箱绑定成功!</h1>";
                } else
                    return "<h1>😱查无此用户,验证失败!</h1>";
            } else {
                return "<h1>😥超出验证时间,验证失败,请重新请求绑定!</h1>";
            }
        } catch (Exception e) {
            return "<h1>🤒检测到非法的secret</h1>";
        }
    }
}

完整的项目

完整的项目地址 👉 eduroamControlSystem-Backend

链接的一次性

当然了, 如果你要求更加严格的话可以考虑将生成后的secret保存到数据库, 然后在用户点击验证的时候查询一遍该secret是否在库中, 存在则说明该secret有效, 就将secret从库中删除并进行下一步操作, 不在则说明该secret是无效的.

这样能够保证secret的有效性和一次性, 数据库的话可以考虑使用redis以保证效率.

url的编码问题

这里需要注意的是, 本来一个secret是 👇

oUWyWz0MWtTxbKQtu1KVugVTvzR3ERTekz2tDettUqqtZ3No3XDqA6PE5zFUFsYHlkj+VmUiuGwh5tlMQBRKPh+25Tmt779zbuxhnbSBXUZ4aeRmwpMHfRx6/+ItVw+5

但是作为参数传入controller的方法后, 打印出来却是 👇

oUWyWz0MWtTxbKQtu1KVugVTvzR3ERTekz2tDettUqqtZ3No3XDqA6PE5zFUFsYHlkj VmUiuGwh5tlMQBRKPh 25Tmt779zbuxhnbSBXUZ4aeRmwpMHfRx6/ ItVw 5

可以发现所有的+都被替换成了空格, 这样在解码的时候就会出现问题
这里由于AES在加密的时候不会产生空格, 所以整个secret一定不会有空格, 故你可以使用替换的方法将接收到的secret中的空格直接替换成+, 这也是我采用的; 或者你也可以使用URLEncoder进行编码

SMTP使用25端口还是465端口

SMTP协议默认是使用25端口, 但是部分云服务器提供商考虑到smtp服务的安全性问题, 可能会封闭25端口, 如果是这样的话, 那么程序将无法通过25端口连接到邮件服务提供商的服务器, 会报Could not connect to SMTP host的错误, 所以这里我们需要换下端口, 使用465端口进行发件 👇

props.setProperty("mail.smtp.port", "465");
props.setProperty("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");
props.setProperty("mail.smtp.socketFactory.fallback", "false");
props.setProperty("mail.smtp.socketFactory.port", "465");

值得一提的是, 465端口是SMTPS服务的端口, SMTPS协议是SMTP协议基于SSL协议的变种协议, 较SMTP协议更安全, 当然了, 可以类比HTTPS之于HTTP.