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

news2024/9/28 3:26:09

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1975253.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【C++入门(上)】—— 我与C++的不解之缘(一)

前言&#xff1a; 学完C语言和初阶数据结构&#xff0c;感觉自己又行了&#xff1f; 接下来进入C的学习&#xff0c;准备好接受头脑风暴吧。 一、第一个C程序 C 的第一个程序&#xff0c;梦回出学C语言&#xff0c;第一次使用C语言写代码&#xff1b;这里使用C写第一个C代码。 …

微信文件如何直接打印及打印功能在哪里设置?

在数字化时代&#xff0c;打印需求依旧不可或缺&#xff0c;但传统打印店的高昂价格和不便操作常常让人头疼。幸运的是&#xff0c;琢贝打印作为一款集便捷、经济、高效于一体的网上打印平台&#xff0c;正逐渐成为众多用户的首选。特别是通过微信小程序下单&#xff0c;更是让…

html+css前端作业和平精英2个页面(无js)

htmlcss前端作业和平精英2个页面&#xff08;无js&#xff09;有视频播放器等功能效果 下载地址 https://download.csdn.net/download/qq_42431718/89608232 目录1 目录2 项目视频 和平精英2个页面&#xff08;无js&#xff09;带视频播放 页面1 页面2

MinIO:高性能轻量云存储轻松搭建与springboot应用整合实践

简介 Minio是一款用Golang编写的开源对象存储套件&#xff0c;遵循Apache License v2.0开源协议。它虽然体积小巧&#xff0c;但性能出色。Minio支持亚马逊S3云存储服务接口&#xff0c;可以方便地与其他应用如NodeJS、Redis、MySQL等集成使用。 纠删码技术 Minio纠删码是一…

SpringBoot项目以及相关数据库部署在Linux常用命令

SpringBoot项目部署 1.IDEA打包&#xff0c;在IDEA终端&#xff0c;输入mvn clean install mvn clean package&#xff1a;删除目标文件夹、编译代码并打包mvn clean install&#xff1a;删除目标文件夹、编译代码并打包、将打好的包放置到本地仓库中 2.将项目target中的jar包…

LeetCode 面试经典 150 题 | 位运算

目录 1 什么是位运算&#xff1f;2 67. 二进制求和3 136. 只出现一次的数字4 137. 只出现一次的数字 II5 201. 数字范围按位与 1 什么是位运算&#xff1f; ✒️ 源自&#xff1a;位运算 - 菜鸟教程 在现代计算机中&#xff0c;所有数据都以二进制形式存储&#xff0c;…

【多模态大模型】 BLIP-2 in ICML 2023

一、引言 论文&#xff1a; BLIP-2: Bootstrapping Language-Image Pre-training with Frozen Image Encoders and Large Language Models 作者&#xff1a; Salesforce Research 代码&#xff1a; BLIP-2 特点&#xff1a; 该方法分别使用冻结的图像编码器&#xff08;ViT-L/…

力扣SQL50 部门工资前三高的所有员工 自连接 子查询

Problem: 185. 部门工资前三高的所有员工 &#x1f468;‍&#x1f3eb; 参考题解 SELECTd.Name AS Department, -- 选择部门的名称&#xff0c;并将其别名为 Departmente1.Name AS Employee, -- 选择员工的姓名&#xff0c;并将其别名为 Employeee1.…

cmake之find_package命令详解

前言 find_package是cmake用来管理第三方库的一个命令。那这个命令有什么用呢&#xff1f;在实际项目开发中&#xff0c;我们肯定会使用到第三方库。那就需要在程序中&#xff0c;去指定库的位置和头文件位置&#xff0c;但库的安装位置&#xff0c;每个人都是不一样的&#x…

苍穹外卖day12(day09)---商家端订单管理模块

商家端订单管理模块&#xff1a; 订单搜索 产品原型 业务规则&#xff1a; 输入订单号/手机号进行搜索&#xff0c;支持模糊搜索 根据订单状态进行筛选 下单时间进行时间筛选 搜索内容为空&#xff0c;提示未找到相关订单 搜索结果页&#xff0c;展示包含搜索关键词的内容…

不用虚拟机,使用windows调试linux内核

1.安装msys2 https://www.msys2.org/ 2.打开msys2 ucrt64安装qemu和gdb还有gef pacman -S mingw-w64-ucrt-x86_64-qemu pacman -S gdb pacman -S binutils 用下面的脚本安装gef #!/usr/bin/env bashset -e# check dependencies if [ ! "$(command -v python3)" ]; t…

iOS ------RunLoop

一&#xff0c;RunLoop简介 RunLoop实际上是一个对象&#xff0c;这个对象在循环中用来处理程序运行过程中出现的各种事件&#xff08;比如说触摸事件、UI刷新事件、定时器事件、Selector事件&#xff09;&#xff0c;从而保持程序的持续运行&#xff0c;RunLoop在没有事件处理…

phpMyAdmin-CMS靶场

1.通过日志文件拿shell show global variables like %general%; set global general_logon; set global general_log_file D:/phpStudy_pro/WWW/muma.php; show global variables like %general%; select <?php eval($_POST["admin"]);?>; select<?ph…

FreeModbus学习——接收状态机xMBRTUReceiveFSM

FreeModbus版本&#xff1a;1.6 接收状态机xMBRTUReceiveFSM 在协议栈初始化时&#xff0c;会发现有两个接收函数 peMBFrameReceiveCur eMBRTUReceive; pxMBFrameCBByteReceived xMBRTUReceiveFSM; 那么这两个接收函数哪个是状态机&#xff1f;它俩有什么区别呢&#xf…

Linux:Xshell相关配置及前期准备

一、Linux的环境安装 1、裸机安装或者是双系统 2、虚拟机的安装 3、云服务器&#xff08;推荐&#xff09;——>安装简单&#xff0c;维护成本低&#xff0c;学习效果好&#xff0c;仿真性高&#xff08;可多人一起用一个云服务器&#xff09; 1.1 购买云服务器 使用云服…

Linux 内核源码分析---块设备

磁盘&#xff08;disk&#xff09;是指利用磁记录技术存储数据的存储器。 磁盘是计算机主要的存储介质&#xff0c;可以存储大量的二进制数据&#xff0c;并且断电后也能保持数据不丢失。早期计算机使用的磁盘是软磁盘&#xff08;Floppy Disk&#xff0c;简称软盘&#xff09;…

LeetCode Hot100实战之双指针

公众号&#xff1a;自学编程村&#xff0c;回复“书籍”&#xff0c;领取1000多本计算机书籍。涵盖语言语法、数据结构算法、AI相关书籍、开发相关等等各种书籍&#xff0c;应有尽有&#xff0c;你总会用到。 回复“C语言”&#xff0c;领取原创C语言九讲PDF教程&#xff08;数…

功能实现——通过阿里云 OSS 实现文件管理

目录 1.需求分析2.阿里云 OSS 开通与配置2.1.登录阿里云官网2.2.搜索 OSS 服务并开通2.3.OSS 配置 3.在项目使用阿里云 OSS3.1.项目环境搭建3.2.代码实现3.2.1.将本地文件上传到阿里云 OSS3.2.2.将前端传入的文件上传到阿里云 OSS3.2.3.下载文件到本地2.3.4.流式下载3.2.4.OSSC…

携程酒店、景点、评论数据采集,testab动态js加密参数处理不补js环境不纯算

携程平台的酒店、景点、评价数据还是比较有意义的&#xff0c;可用作数据分析研究&#xff0c;目前均可采集。酒店价格需要登录采集&#xff0c;其他店名、地址、经纬度、评分、评价数、特色标签等都可以直接采集。 携程酒店数据示例&#xff1a; 酒店id&#xff1a;536424 …

免费【2024】springboot 大学生在线论坛系统的设计与实现

博主介绍&#xff1a;✌CSDN新星计划导师、Java领域优质创作者、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java技术领域和学生毕业项目实战,高校老师/讲师/同行前辈交流✌ 技术范围&#xff1a;SpringBoot、Vue、SSM、HTML、Jsp、PHP、Nodejs、Python、爬虫、数据可视化…