1. 前提条件
1.1 Redis
1.1.1 拉取 Redis 镜像
docker pull redis:latest1.1.2 启动 Redis 容器
docker run --name my-redis -d -p 6379:6379 redis:latest
1.2 Kafka
1.2.1 docker-compose.yml
version: '3.8'
services:
  zookeeper:
    image: "zookeeper:latest"
    hostname: 192.168.186.77
    container_name: zookeeper1
    ports:
      - "2181:2181"
      - "2888:2888"
      - "3888:3888"
    environment:
      ZOO_MY_ID: 1
      ZOO_SERVERS: server.1=192.168.186.77:2888:3888;2181
    volumes:
      - ./data:/data
    restart: always
  kafka:
    image: "wurstmeister/kafka:latest"
    hostname: 192.168.186.77
    container_name: kafka1
    ports:
      - "9092:9092"
    environment:
      KAFKA_BROKER_ID: 1
      KAFKA_ZOOKEEPER_CONNECT: 192.168.186.77:2181
      KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://192.168.186.77:9092
      KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true'
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    depends_on:
      - zookeeper
    restart: always
说明:将192.168.186.77替换为你实际的IP地址。
1.2.2 启动 Kafka 容器
 docker-compose up -d说明:需要先进入docker-compose.yml所在的路径下再启动,会自动拉起相关的镜像,本例基于单个zookeeper的单个Kafka,并没有涉及到集群。
1.2.3 验证 Kafka
docker exec -it kafka1 kafka-topics.sh --list --bootstrap-server localhost:9092说明:验证主题是否成功创建 。
1.3 QQ邮箱
 1.3.1 点击设置    
 
1.3.2 账号->开启服务

说明:下拉找到POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务,开启服务。
1.3.3 获取授权码

说明:授权码相当于邮箱的密码,在application.yml中配置password属性。
2. 设计思路
2.1 架构设计
- Kafka:处理消息传递,确保异步发送和接收验证码及注册验证。
- Redis:存储验证码和临时用户数据,保证高效读取和验证。
- JWT Token:用于注册和登录的验证,确保用户身份的安全性。
- Thymeleaf:作为视图模板引擎,用于动态生成前端页面。
2.2 设计流程
2.2.1 注册
1. 用户在前端填写邮箱和密码进行注册。
 2. 生成JWT Token并缓存用户信息到Redis。
 3. Kafka发送注册验证邮件,邮件中包含验证链接(带有Token)。
 4. 用户点击验证链接,后端验证Token有效性并从Redis中读取用户数据存入数据库。
2.2.2 登录/重置
1. 用户在前端填写邮箱请求验证码。
 2. 生成随机6位验证码并加密缓存到Redis。
 3. Kafka发送验证码到用户邮箱(限制五分钟内只能发一次邮箱验证)
 4. 用户输入验证码进行验证,后端从Redis中读取并解密验证验证码有效性。 
3. 项目结构

 
4. 数据库操作
create database email_registration;说明:只需要创建数据库即可,JPA会根据实体创建对应的表。
5. Maven依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.2</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>org.example</groupId>
    <artifactId>spring-email</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>spring-email</name>
    <properties>
        <java.version>17</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-mail</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.kafka</groupId>
            <artifactId>spring-kafka</artifactId>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <!-- https://mvnrepository.com/artifact/com.auth0/java-jwt -->
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>4.4.0</version>
        </dependency>
        <dependency>
            <groupId>org.jetbrains</groupId>
            <artifactId>annotations</artifactId>
            <version>13.0</version>
            <scope>compile</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>
6. application.yml
spring:
  application:
    name: spring-email
  datasource:
    url: jdbc:mysql://localhost:3306/email_registration
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
    open-in-view: false
  data:
    redis:
      host: 192.168.186.77
      port: 6379
  mail:
    host: smtp.qq.com
    port: 465
    username: QQ邮箱账号
    password: 获取的QQ邮箱授权码
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
          ssl:
            enable: true
            required: true
            trust: smtp.qq.com
          socketFactory:
            port: 465
            class: javax.net.ssl.SSLSocketFactory
      mime:
        filetype:
          map: classpath:mime.types
  kafka:
    bootstrap-servers: 192.168.186.77:9092
    consumer:
      group-id: email-registration-group
      auto-offset-reset: earliest
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.apache.kafka.common.serialization.StringSerializer
jwt:
 secret_key: H9ylG13Otn6ZRC0LhMy+cyu5TJzU4sT2LPAFJjRJt9Q=
 expire_time: 15 # minute
 request_limit: 5 # minute说明:配置MySQL数据库,Redis,Kafka,以及QQ邮箱的配置信息。
7. 后端(SpringBoot)
7.1 SpringEmailApplication.java
package org.example;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@SpringBootApplication
public class SpringEmailApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringEmailApplication.class, args);
    }
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
说明:SpringBoot启动类,在这里注册PasswordEncoder的原因是防止出现循环注入。
7.2 JwtUtil.java
package org.example.util;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.auth0.jwt.interfaces.JWTVerifier;
import org.example.entity.EmailType;
import org.example.entity.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.Date;
import java.util.concurrent.TimeUnit;
@Component
public class JwtUtil {
    @Value("${jwt.secret_key}")
    private String SECRET_KEY;
    @Value("${jwt.expire_time}")
    private long EXPIRATION_TIME;
    @Value("${jwt.request_limit}")
    private long REQUEST_LIMIT_DURATION;
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    private static final SecureRandom random = new SecureRandom();
    private static final String ALGORITHM = "AES";
    // 生成token令牌
    public String generateToken(String email) {
        return JWT.create()
                .withSubject(email)
                .withIssuedAt(Date.from(Instant.now()))
                .withExpiresAt(Date.from(Instant.now().plus(EXPIRATION_TIME, ChronoUnit.MINUTES)))
                .sign(Algorithm.HMAC512(SECRET_KEY));
    }
    // 通过token获取邮箱
    public String getEmailFromToken(String token) {
        try {
            return JWT.decode(token).getSubject();
        } catch (JWTDecodeException exception) {
            return null;
        }
    }
    // 验证token是否有效
    public boolean isTokenExpired(String token) {
        try {
            JWTVerifier verifier = JWT.require(Algorithm.HMAC512(SECRET_KEY)).build();
            DecodedJWT jwt = verifier.verify(token);
            return jwt.getExpiresAt().before(new Date());
        } catch (JWTVerificationException exception) {
            return true;
        }
    }
    // 缓存 token
    public void cacheToken(String token) {
        redisTemplate.opsForValue().set(token, "", 1800, TimeUnit.SECONDS);
    }
    // 删除token
    public void deleteToken(String token) {
        redisTemplate.delete(token);
    }
    // 缓存用户到redis
    public void cacheUser(User user) {
        redisTemplate.opsForValue().set(user.getEmail(), user, 1800, TimeUnit.SECONDS);
    }
    // 获取缓存的注册用户
    public User getUser(String token) {
        if (isTokenExpired(token)) return null;
        String email = getEmailFromToken(token);
        User user = (User) redisTemplate.opsForValue().get(email);
        deleteToken(token);
        delete(email);
        return user;
    }
    // 通过邮箱删除用户
    public void delete(String email) {
        redisTemplate.delete(email);
    }
    // 缓存验证码
    public void cacheCode(String email, String code, EmailType type) {
        try {
            String encryptedCode = encryptCode(code);
            String key = email + ":" + type.name();
            redisTemplate.opsForValue().set(key, encryptedCode, 300, TimeUnit.SECONDS);
        } catch (Exception e) {
            throw new RuntimeException("Failed to encrypt code", e);
        }
    }
    // 从redis获取验证码解密
    public String getCode(String email, EmailType type) {
        String key = email + ":" + type.name();
        String encryptedCode = (String) redisTemplate.opsForValue().get(key);
        if (encryptedCode == null) {
            return null;
        }
        try {
            return decryptCode(encryptedCode);
        } catch (Exception e) {
            throw new RuntimeException("Failed to decrypt code", e);
        }
    }
    // 生成验证码
    public String generateCode() {
        int code = random.nextInt((int) Math.pow(10, 6));
        return String.format("%06d", code);
    }
    // 验证验证码
    public boolean verifyCode(String email, String code, EmailType type) {
        String cachedCode = getCode(email, type);
        return cachedCode != null && cachedCode.equals(code);
    }
    // 加密验证码
    private String encryptCode(String code) throws Exception {
        Cipher cipher = Cipher.getInstance(ALGORITHM);
        SecretKeySpec secretKeySpec = getSecretKeySpec();
        cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec);
        byte[] encryptedBytes = cipher.doFinal(code.getBytes());
        return Base64.getEncoder().encodeToString(encryptedBytes);
    }
    // 解密验证码
    private String decryptCode(String encryptedCode) throws Exception {
        Cipher cipher = Cipher.getInstance(ALGORITHM);
        SecretKeySpec secretKeySpec = getSecretKeySpec();
        cipher.init(Cipher.DECRYPT_MODE, secretKeySpec);
        byte[] decodedBytes = Base64.getDecoder().decode(encryptedCode);
        byte[] decryptedBytes = cipher.doFinal(decodedBytes);
        return new String(decryptedBytes);
    }
    // 密钥字符串转换
    private SecretKeySpec getSecretKeySpec() {
        byte[] decodedKey = Base64.getDecoder().decode(SECRET_KEY);
        return new SecretKeySpec(decodedKey, 0, decodedKey.length, ALGORITHM);
    }
    // 缓存请求时间戳
    public void cacheRequestTimestamp(String email) {
        long currentTimestamp = Instant.now().getEpochSecond();
        // 使用 TimeUnit.MINUTES 表示过期时间
        redisTemplate.opsForValue().set(email + ":request-timestamp", currentTimestamp, REQUEST_LIMIT_DURATION, TimeUnit.MINUTES);
    }
    // 检查请求是否允许
    public boolean isRequestAllowed(String email) {
        // 确保从 Redis 中读取的时间戳是 Long 类型
        Object lastRequestTimestampObj = redisTemplate.opsForValue().get(email + ":request-timestamp");
        if (lastRequestTimestampObj == null) {
            return true;
        }
        long lastRequestTimestamp = ((Number) lastRequestTimestampObj).longValue();
        long currentTimestamp = Instant.now().getEpochSecond();
        return currentTimestamp - lastRequestTimestamp >= REQUEST_LIMIT_DURATION * 60;
    }
}
说明:redis的一些简单的配置操作,比如加密、解密、验证等。
7.3 UserService.java
package org.example.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.example.entity.EmailType;
import org.example.entity.User;
import org.example.repository.UserRepository;
import org.example.util.JwtUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
@Service
public class UserService implements UserDetailsService {
    @Autowired
    private UserRepository userRepository;
    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;
    @Autowired
    private PasswordEncoder passwordEncoder;
    @Autowired
    private JwtUtil jwtUtil;
    public void registerUser(String email, String password) {
        // 注册之前先查询数据库用户是否已经存在
        if (userRepository.findByEmail(email) != null) {
            throw new RuntimeException("User already exists");
        }
        // 不存在创建新用户
        User user = new User();
        user.setEmail(email);
        user.setPassword(passwordEncoder.encode(password));
        user.setVerified(false);
        jwtUtil.cacheUser(user); // 将用户信息缓存到redis
        String token = jwtUtil.generateToken(email); // 生成token
        jwtUtil.cacheToken(token); // 缓存token
        Map<String, String> message = new HashMap<>(); // 发送邮箱
        message.put("type", EmailType.REGISTER.name());
        message.put("token", token); // 发送token到kafka
        try {
            kafkaTemplate.send("email_verification", email, new ObjectMapper().writeValueAsString(message));
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }
    public void sendEmail(String email, EmailType emailType) {
        try {
            // 检查请求是否允许
            if (!jwtUtil.isRequestAllowed(email)) {
                throw new RuntimeException("Request limit reached. Please wait for 5 minutes before trying again.");
            }
            if (userRepository.findByEmail(email) == null) {
                throw new RuntimeException("User does not exist");
            }
            String code = jwtUtil.generateCode();
            jwtUtil.cacheCode(email, code, emailType);
            // 缓存请求时间戳
            jwtUtil.cacheRequestTimestamp(email);
            Map<String, String> message = new HashMap<>();
            message.put("type", emailType.name());
            message.put("code", code);
            kafkaTemplate.send("email_verification", email, new ObjectMapper().writeValueAsString(message));
        } catch (JsonProcessingException e) {
            throw new RuntimeException("Failed to serialize message", e);
        }
    }
    public boolean verifyUser(String token) {
        User user = jwtUtil.getUser(token);
        if (user != null) {
            user.setVerified(true);
            userRepository.save(user);
            return true;
        }
        return false;
    }
    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        User user = userRepository.findByEmail(email);
        if (user == null) {
            throw new UsernameNotFoundException("User not found");
        }
        return org.springframework.security.core.userdetails.User
                .withUsername(email)
                .password(user.getPassword()).build();
    }
    public boolean verifyCode(String email, String code, EmailType type) {
        return jwtUtil.verifyCode(email, code, type);
    }
}
说明:一些注册和登录的逻辑处理等。
7.4 KafkaConsumerService.java
package org.example.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.example.entity.EmailType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.SpringTemplateEngine;
import java.io.IOException;
import java.util.Map;
@Service
public class KafkaConsumerService {
    @Autowired
    private JavaMailSender mailSender;
    @Autowired
    private SpringTemplateEngine templateEngine;
    @Value("${spring.mail.username}")
    private String fromAddress;
    @KafkaListener(topics = "email_verification", groupId = "email-registration-group")
    public void handleEmailVerification(ConsumerRecord<String, String> record) {
        String email = record.key();
        String value = record.value();
        try {
            Map<String, String> message = new ObjectMapper().readValue(value, new TypeReference<Map<String, String>>() {});
            EmailType emailType = EmailType.valueOf(message.get("type"));
            String subject = "";
            String template = "email";
            Context context = new Context();
            context.setVariable("title", "");
            context.setVariable("message", "");
            context.setVariable("link", "");
            switch (emailType) {
                case REGISTER:
                    String token = message.get("token");
                    subject = "Email Verification";
                    context.setVariable("title", "Verify your email");
                    context.setVariable("message", "Click the link below to verify your email:");
                    context.setVariable("link", "http://localhost:8080/auth/verify?token=" + token);
                    break;
                case LOGIN:
                    String loginCode = message.get("code");
                    subject = "Login Verification Code";
                    context.setVariable("title", "Login Verification Code");
                    context.setVariable("message", "Your login verification code is: " + loginCode);
                    context.setVariable("link", null);
                    break;
                case RESET_PASSWORD:
                    String resetCode = message.get("code");
                    subject = "Password Reset";
                    context.setVariable("title", "Reset Your Password");
                    context.setVariable("message", "Your reset password code is: " + resetCode);
                    context.setVariable("link", null);
                    break;
            }
            String content = templateEngine.process(template, context);
            sendEmail(email, subject, content);
        } catch (IOException e) {
            // 记录日志
            System.err.println("Error processing email verification message: " + e.getMessage());
        }
    }
    private void sendEmail(String to, String subject, String htmlContent) {
        MimeMessage mimeMessage = mailSender.createMimeMessage();
        try {
            MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8");
            helper.setTo(to);
            helper.setFrom(fromAddress); // 使用配置的发件人地址
            helper.setSubject(subject);
            helper.setText(htmlContent, true); // 第二个参数true表示这是HTML内容
            mailSender.send(mimeMessage);
        } catch (MessagingException e) {
            // 记录日志
            System.err.println("Error sending email: " + e.getMessage());
        }
    }
}
说明:Kafka的邮箱发送操作,同时通过模板引擎动态生成HTML页面发送邮箱。
7.5 UserRepository.java
package org.example.repository;
import org.example.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    User findByEmail(String email);
}7.6 User.java
package org.example.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.Setter;
@Setter
@Getter
@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String email;
    private String password;
    private boolean verified;
}
7.7 EmailType.java
package org.example.entity;
public enum EmailType {
    REGISTER,
    LOGIN,
    RESET_PASSWORD
}
说明:通过枚举代表不同邮箱发送的验证类型。
7.8 UserController.java
package org.example.controller;
import org.example.entity.EmailType;
import org.example.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@Controller
@RequestMapping("/auth")
public class UserController {
    @Autowired
    private UserService userService;
    @PostMapping("/register")
    @ResponseBody
    public ResponseEntity<Map<String, String>> registerUser(@RequestBody Map<String, String> request) {
        String email = request.get("email");
        String password = request.get("password");
        Map<String, String> response = new HashMap<>();
        try {
            userService.registerUser(email, password);
            response.put("message", "Registration link sent successfully.");
        } catch (RuntimeException e) {
            response.put("message", "Error during registration: " + e.getMessage());
        }
        return ResponseEntity.ok(response);
    }
    @GetMapping("/verify")
    public String verifyUser(@RequestParam String token, Model model) {
        if (userService.verifyUser(token)) {
            model.addAttribute("message", "Email verified successfully!");
        } else {
            model.addAttribute("message", "Invalid or expired verification token.");
        }
        return "verify";
    }
    @PostMapping("/login-code")
    @ResponseBody
    public ResponseEntity<Map<String, String>> sendVerificationCode(@RequestBody Map<String, String> request) {
        return sendCode(request, EmailType.LOGIN);
    }
    @PostMapping("/recover-code")
    @ResponseBody
    public ResponseEntity<Map<String, String>> sendReSetVerificationCode(@RequestBody Map<String, String> request) {
        return sendCode(request, EmailType.RESET_PASSWORD);
    }
    private ResponseEntity<Map<String, String>> sendCode(Map<String, String> request, EmailType emailType) {
        String email = request.get("email");
        Map<String, String> response = new HashMap<>();
        try {
            userService.sendEmail(email, emailType);
            response.put("message", "Verification code sent to your email.");
        } catch (Exception e) {
            response.put("message", e.getMessage());
        }
        return ResponseEntity.ok(response);
    }
    @PostMapping("/verify-login-code")
    @ResponseBody
    public ResponseEntity<Map<String, String>> verifyLoginCode(@RequestBody Map<String, String> request) {
        String email = request.get("email");
        String code = request.get("code");
        boolean isValid = userService.verifyCode(email, code, EmailType.LOGIN);
        Map<String, String> response = new HashMap<>();
        if (isValid) {
            response.put("message", "Login successful.");
        } else {
            response.put("message", "Invalid verification code.");
        }
        return ResponseEntity.ok(response);
    }
    @PostMapping("/verify-recover-code")
    @ResponseBody
    public ResponseEntity<Map<String, String>> verifyRecoverCode(@RequestBody Map<String, String> request) {
        String email = request.get("email");
        String code = request.get("code");
        boolean isValid = userService.verifyCode(email, code, EmailType.RESET_PASSWORD);
        Map<String, String> response = new HashMap<>();
        if (isValid) {
            response.put("message", "Verification successful. You can now reset your password.");
        } else {
            response.put("message", "Invalid verification code.");
        }
        return ResponseEntity.ok(response);
    }
}
说明:提供的用户对外处理认证接口。
7.9 IndexController.java
package org.example.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class IndexController {
    @GetMapping("/")
    public String index() {
        return "index";
    }
}
7.10 SecurityConfig.java
package org.example.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
    @Autowired
    private UserDetailsService userDetailsService;
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(authz -> authz
                        .requestMatchers("/auth/**","/","/css/**").permitAll()
                        .anyRequest().authenticated()
                )
                .formLogin(AbstractHttpConfigurer::disable)
                .userDetailsService(userDetailsService);
        return http.build();
    }
}
说明:禁用CRSF认证,同时放行一些无需认证的接口。
7.11 RedisConfig.java
package org.example.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
        return template;
    }
}
说明:Redis的配置类,序列化和反序列化。
7.12 KafkaNewTopicConfig.java
package org.example.config;
import org.apache.kafka.clients.admin.NewTopic;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.annotation.EnableKafka;
import org.springframework.kafka.config.TopicBuilder;
@Configuration
@EnableKafka
public class KafkaNewTopicConfig {
    @Bean
    public NewTopic emailVerificationTopic() {
        return TopicBuilder.name("email_verification")
                .partitions(3)
                .replicas(1)
                .build();
    }
}
说明:Kafka创建新的主题。
8. 前端(Thymeleaf 模板引擎)
8.1 styles.css
body {
    background-color: #f8f9fa;
}
.card {
    border-radius: 15px;
}
.card-header {
    background-color: #007bff;
    color: white;
    border-top-left-radius: 15px;
    border-top-right-radius: 15px;
}
.btn-primary {
    background-color: #007bff;
    border: none;
}
.btn-primary:hover {
    background-color: #0056b3;
}
.input-group-text {
    background-color: #007bff;
    color: white;
    border: none;
}
.nav-tabs .nav-link.active {
    background-color: #e9ecef;
    border-color: #dee2e6 #dee2e6 #fff;
    color: #495057;
}
.nav-tabs .nav-link {
    color: #007bff;
}
8.2 head.html
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <title>Authentication System</title>
    <!-- 引入Bootstrap CSS -->
    <link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.1.3/css/bootstrap.min.css">
    <!-- 引入Bootstrap Icons -->
    <link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/bootstrap-icons/1.8.1/font/bootstrap-icons.min.css">
    <!-- 自定义CSS -->
    <link rel="stylesheet" href="@{/css/styles.css}">
</head>
8.3 email.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <style>
        @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap');
        body {
            font-family: 'Roboto', sans-serif;
            background-color: #f4f4f4;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            margin: 0;
        }
        .container {
            max-width: 600px;
            background-color: #ffffff;
            padding: 30px;
            border-radius: 15px;
            box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
            text-align: center;
            transition: transform 0.3s;
        }
        .container:hover {
            transform: translateY(-5px);
        }
        h1 {
            color: #333333;
            font-size: 28px;
            margin-bottom: 20px;
        }
        p {
            color: #555555;
            line-height: 1.8;
            font-size: 16px;
            margin-bottom: 30px;
        }
        a {
            display: inline-block;
            padding: 12px 25px;
            font-size: 16px;
            color: #ffffff;
            background-color: #007BFF;
            text-decoration: none;
            border-radius: 5px;
            transition: background-color 0.3s, transform 0.3s;
        }
        a:hover {
            background-color: #0056b3;
            transform: translateY(-2px);
        }
        .footer {
            margin-top: 20px;
            color: #888888;
            font-size: 14px;
        }
    </style>
</head>
<body>
<div class="container">
    <h1 th:text="${title}">Title</h1>
    <p th:text="${message}">Message</p>
    <a th:href="${link}" th:if="${link != null}">Click here</a>
    <div class="footer">
        <p>Thank you for using our service!</p>
    </div>
</div>
</body>
</html>
说明:该HTML和Kafka发送的邮箱的样式对应。
8.4 index.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/head}"></head>
<body>
<div class="container mt-5">
    <div class="row justify-content-center">
        <div class="col-md-6">
            <div class="card shadow-sm">
                <div class="card-header text-center">
                    <h2>欢迎</h2>
                </div>
                <div class="card-body">
                    <ul class="nav nav-tabs justify-content-center" id="myTab" role="tablist">
                        <li class="nav-item" role="presentation">
                            <a class="nav-link active" id="login-tab" data-bs-toggle="tab" href="#login" role="tab" aria-controls="login" aria-selected="true">登录</a>
                        </li>
                        <li class="nav-item" role="presentation">
                            <a class="nav-link" id="register-tab" data-bs-toggle="tab" href="#register" role="tab" aria-controls="register" aria-selected="false">注册</a>
                        </li>
                        <li class="nav-item" role="presentation">
                            <a class="nav-link" id="recover-tab" data-bs-toggle="tab" href="#recover" role="tab" aria-controls="recover" aria-selected="false">找回密码</a>
                        </li>
                    </ul>
                    <div class="tab-content mt-4" id="myTabContent">
                        <div class="tab-pane fade show active" id="login" role="tabpanel" aria-labelledby="login-tab">
                            <h3 class="text-center">登录</h3>
                            <form id="login-form">
                                <div class="mb-3 input-group">
                                    <span class="input-group-text"><i class="bi bi-envelope"></i></span>
                                    <input type="email" class="form-control" id="login-email" name="email" placeholder="邮箱" required>
                                </div>
                                <div class="mb-3 input-group">
                                    <span class="input-group-text"><i class="bi bi-key"></i></span>
                                    <input type="text" class="form-control" id="login-code" name="code" placeholder="验证码">
                                </div>
                                <button type="button" class="btn btn-primary w-100" onclick="sendLoginCode()">发送验证码</button>
                                <button type="button" class="btn btn-success w-100 mt-2" onclick="verifyLoginCode()">验证验证码</button>
                            </form>
                        </div>
                        <div class="tab-pane fade" id="register" role="tabpanel" aria-labelledby="register-tab">
                            <h3 class="text-center">注册</h3>
                            <form id="register-form">
                                <div class="mb-3 input-group">
                                    <span class="input-group-text"><i class="bi bi-envelope"></i></span>
                                    <input type="email" class="form-control" id="register-email" name="email" placeholder="邮箱" required>
                                </div>
                                <div class="mb-3 input-group">
                                    <span class="input-group-text"><i class="bi bi-lock"></i></span>
                                    <input type="password" class="form-control" id="register-password" name="password" placeholder="密码" required>
                                </div>
                                <button type="button" class="btn btn-primary w-100" onclick="submitRegisterForm()">注册</button>
                            </form>
                        </div>
                        <div class="tab-pane fade" id="recover" role="tabpanel" aria-labelledby="recover-tab">
                            <h3 class="text-center">找回密码</h3>
                            <form id="recover-form">
                                <div class="mb-3 input-group">
                                    <span class="input-group-text"><i class="bi bi-envelope"></i></span>
                                    <input type="email" class="form-control" id="recover-email" name="email" placeholder="邮箱" required>
                                </div>
                                <div class="mb-3 input-group">
                                    <span class="input-group-text"><i class="bi bi-key"></i></span>
                                    <input type="text" class="form-control" id="recover-code" name="code" placeholder="验证码">
                                </div>
                                <button type="button" class="btn btn-primary w-100" onclick="sendRecoverCode()">发送验证码</button>
                                <button type="button" class="btn btn-success w-100 mt-2" onclick="verifyRecoverCode()">验证验证码</button>
                            </form>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>
<!-- 引入 Axios -->
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.1.3/axios.min.js"></script>
<!-- 引入 Bootstrap JS -->
<script src="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.1.3/js/bootstrap.bundle.min.js"></script>
<!-- 自定义脚本 -->
<script>
    function sendLoginCode() {
        const email = document.getElementById('login-email').value;
        axios.post('/auth/login-code', { email: email })
            .then(response => {
                alert(response.data.message);
            })
            .catch(error => {
                console.error('There was an error!', error);
                alert('Failed to send verification code!');
            });
    }
    function verifyLoginCode() {
        const email = document.getElementById('login-email').value;
        const code = document.getElementById('login-code').value;
        axios.post('/auth/verify-login-code', { email: email, code: code })
            .then(response => {
                alert(response.data.message);
            })
            .catch(error => {
                console.error('There was an error!', error);
                alert('Failed to verify code!');
            });
    }
    function submitRegisterForm() {
        const email = document.getElementById('register-email').value;
        const password = document.getElementById('register-password').value;
        axios.post('/auth/register', { email: email, password: password })
            .then(response => {
                alert(response.data.message);
            })
            .catch(error => {
                console.error('There was an error!', error);
                alert('Registration failed!');
            });
    }
    function sendRecoverCode() {
        const email = document.getElementById('recover-email').value;
        axios.post('/auth/recover-code', { email: email })
            .then(response => {
                alert(response.data.message);
            })
            .catch(error => {
                console.error('There was an error!', error);
                alert('Failed to send verification code!');
            });
    }
    function verifyRecoverCode() {
        const email = document.getElementById('recover-email').value;
        const code = document.getElementById('recover-code').value;
        axios.post('/auth/verify-recover-code', { email: email, code: code })
            .then(response => {
                alert(response.data.message);
            })
            .catch(error => {
                console.error('There was an error!', error);
                alert('Failed to verify code!');
            });
    }
</script>
</body>
</html>
说明:进行接口测试的HTML。
8.5 verify.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/head}"></head>
<body>
<div class="container mt-5">
    <div class="row justify-content-center">
        <div class="col-md-6">
            <div class="card shadow-sm">
                <div class="card-header text-center">
                    <h2><i class="bi bi-check-circle"></i> 邮箱验证</h2>
                </div>
                <div class="card-body">
                    <div th:text="${message}" class="alert alert-info"></div>
                </div>
            </div>
        </div>
    </div>
</div>
<script src="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.1.3/js/bootstrap.bundle.min.js"></script>
</body>
</html>
说明:验证用户注册是否通过。
9. 测试
9.1 注册
9.1.1 注册前提准备

说明:注册前我数据库是没有任何数据的。
9.1.2 发送注册信息

说明:填写邮箱和密码点击注册,会收到注册验证邮箱。
9.1.3 邮箱查看

说明:查看邮箱,点击Click here进行验证。
9.1.4 继续访问

9.1.5 验证结果

9.1.6 查看控制台

说明:执行了数据库插入语句。
9.1.7 查看数据库

9.2 登录/重置
9.1 测试登录验证码
说明:发送登录验证码
9.2 验证登录验证码
 说明:输入收到的验证码,进行验证码验证。
 说明:输入收到的验证码,进行验证码验证。 
9.3 测试重置验证码

9.4 验证重置验证码

9.5 其他
9.5.1 限制请求

说明:5分钟内只能发送一次验证请求。
9.5.2 加密过的验证码

说明:redis的缓存数据。
9.5.3 微信收到的邮箱

10. 总结
使用Kafka处理消息传递,Redis存储验证码和临时用户数据,JWT进行身份验证,Spring Boot提供开发环境,Thymeleaf生成动态页面,Bootstrap美化前端。实现了用户注册、登录、找回密码功能。注册时生成JWT Token并存储用户信息到Redis,通过Kafka发送验证邮件;登录和找回密码时生成验证码并通过Kafka发送邮件,用户输入验证码进行验证。




















