QQ邮箱 + Kafka + Redis + Thymeleaf 模板引擎实现简单的用户注册认证

1. 前提条件

1.1 Redis

1.1.1 拉取 Redis 镜像

docker pull redis:latest

1.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发送邮件,用户输入验证码进行验证。

  • 14
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值