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