feat(auth): add email verification codes

- Add POST /auth/verification-code endpoint to email a code
- Store codes in Redis and verify them during registration
- Send verification emails asynchronously via Thymeleaf
- Replace email_verified column with code-based verification
- Group dependencies into version catalogue bundles
This commit is contained in:
zihluwang committed 2026-08-19 13:39:36 +08:00
1 parent 8f98d02044
commit d9b74ae2e3
23 files changed
+440 -83

No files matched your search

@@ -2,12 +2,14 @@ package com.onixbyte.deltaforceguide;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
/**
* Entry point for the Delta Force Guide Server application.
*
* @author zihluwang
*/
@EnableAsync
@SpringBootApplication
public class DeltaForceGuideApplication {
@@ -0,0 +1,105 @@
package com.onixbyte.deltaforceguide.client;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.InternetAddress;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import java.util.Map;
@Component
public class EmailClient {
private static final Logger log = LoggerFactory.getLogger(EmailClient.class);
private final JavaMailSender mailSender;
private final TemplateEngine emailTemplateEngine;
@Value("${spring.mail.username}")
private String fromAddress;
@Autowired
public EmailClient(
JavaMailSender mailSender,
TemplateEngine emailTemplateEngine
) {
this.mailSender = mailSender;
this.emailTemplateEngine = emailTemplateEngine;
}
/**
* Send a template-based HTML email asynchronously.
*
* @param toRecipient recipient email address
* @param subject subject line
* @param templateName template name (without .html extension, under mail-templates/)
* @param variables context variables required by the template
*/
@Async
public void sendTemplateMail(String toRecipient, String subject, String templateName, Map<String, Object> variables) {
try {
// Render HTML content using the dedicated email template engine
var context = new Context();
if (variables != null && !variables.isEmpty()) {
context.setVariables(variables);
}
var htmlBody = emailTemplateEngine.process(templateName, context);
log.debug("htmlBody={}", htmlBody);
// Build the MIME message
var message = mailSender.createMimeMessage();
var helper = new MimeMessageHelper(
message,
MimeMessageHelper.MULTIPART_MODE_MIXED_RELATED,
StandardCharsets.UTF_8.name()
);
helper.setFrom(new InternetAddress(fromAddress, "阿萨拉向导", StandardCharsets.UTF_8.name()));
helper.setTo(toRecipient);
helper.setSubject(subject);
helper.setText(htmlBody, true);
// Dispatch the message
mailSender.send(message);
log.info("Successfully dispatched email to: [{}] with subject: [{}]", toRecipient, subject);
} catch (MessagingException | UnsupportedEncodingException ex) {
log.error("Failed to construct or send email to: [{}], subject: [{}]", toRecipient, subject, ex);
// Handle or rethrow custom business exception if required
} catch (Exception ex) {
log.error("Unexpected error occurred while processing email for: [{}]", toRecipient, ex);
}
}
/**
* Convenience method specifically for sending registration/login verification codes.
*
* @param toRecipient target recipient email
* @param username display username
* @param code verification code string
* @param expireMinutes expiration time in minutes
*/
@Async
public void sendVerificationCode(String toRecipient, String username, String code, int expireMinutes) {
var subject = "【阿萨拉向导】账号注册验证码";
var variables = Map.<String, Object>of(
"username", username,
"code", code,
"expireMinutes", expireMinutes
);
sendTemplateMail(toRecipient, subject, "verification-code", variables);
}
}
@@ -0,0 +1,37 @@
package com.onixbyte.deltaforceguide.config;
import com.onixbyte.captcha.text.TextProducer;
import com.onixbyte.captcha.text.impl.DefaultTextProducer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.spring6.SpringTemplateEngine;
import org.thymeleaf.templatemode.TemplateMode;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import org.thymeleaf.templateresolver.ITemplateResolver;
import java.nio.charset.StandardCharsets;
@Configuration
public class EmailTemplateConfig {
@Bean
public TemplateEngine emailTemplateEngine() {
var resolver = new ClassLoaderTemplateResolver();
// Template files are located under src/main/resources/mail-templates/
resolver.setPrefix("mail-templates/");
resolver.setSuffix(".html");
resolver.setTemplateMode(TemplateMode.HTML);
resolver.setCharacterEncoding(StandardCharsets.UTF_8.name());
resolver.setCacheable(true); // Enable caching for the production environment
var templateEngine = new SpringTemplateEngine();
templateEngine.addTemplateResolver(resolver);
return templateEngine;
}
@Bean
public TextProducer textProducer() {
return DefaultTextProducer.builder().build();
}
}
@@ -1,8 +1,6 @@
package com.onixbyte.deltaforceguide.controller;
import com.onixbyte.deltaforceguide.domain.dto.LoginRequest;
import com.onixbyte.deltaforceguide.domain.dto.RegisterRequest;
import com.onixbyte.deltaforceguide.domain.dto.UserResponse;
import com.onixbyte.deltaforceguide.domain.dto.*;
import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth;
import com.onixbyte.deltaforceguide.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
@@ -48,6 +46,13 @@ public class AuthController {
.build();
}
@Operation(description = "发送邮件验证码")
@PostMapping("/verification-code")
public SendVerificationCodeResponse sendVerificationCode(@Validated @RequestBody SendVerificationCodeRequest request) {
var verificationCodeId = authService.sendVerificationCode(request);
return new SendVerificationCodeResponse(verificationCodeId);
}
@Operation(description = "用户注册")
@PostMapping("/register")
public ResponseEntity<UserResponse> register(@Validated @RequestBody RegisterRequest request) {
@@ -24,6 +24,8 @@ public record RegisterRequest(
String avatarUrl,
@NotBlank(message = "密码不能为空")
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED)
String password
String password,
@NotBlank(message = "验证码 ID 不能为空") String verificationCodeId,
@NotBlank(message = "验证码不能为空") String verificationCode
) {
}
@@ -0,0 +1,9 @@
package com.onixbyte.deltaforceguide.domain.dto;
import jakarta.validation.constraints.NotBlank;
public record SendVerificationCodeRequest(
@NotBlank(message = "用户名不能为空") String username,
@NotBlank(message = "电子邮箱地址不能为空") String email
) {
}
@@ -0,0 +1,6 @@
package com.onixbyte.deltaforceguide.domain.dto;
public record SendVerificationCodeResponse(
String verificationCodeId
) {
}
@@ -10,9 +10,5 @@ import jakarta.validation.constraints.Size;
*/
public record UpdateProfileRequest(
@Size(min = 1, max = 64, message = "昵称长度必须在1-64个字符之间")
String nickname,
@Email(message = "请输入正确的电子邮箱地址")
@Size(max = 255, message = "电子邮箱长度不能超过255个字符")
String email
String nickname
) {}
@@ -16,7 +16,6 @@ public record UserProfileResponse(
String nickname,
String email,
String avatarUrl,
Boolean emailVerified,
Boolean enabled,
LocalDateTime createdAt,
List<String> roles
@@ -37,7 +36,6 @@ public record UserProfileResponse(
? user.getUsername() : user.getNickname(),
user.getEmail(),
user.getAvatarUrl(),
user.getEmailVerified(),
user.getEnabled(),
user.getCreatedAt(),
roles
@@ -39,9 +39,6 @@ public class User {
@Column(name = "avatar_url")
private String avatarUrl;
@Column(name = "email_verified", nullable = false)
private Boolean emailVerified = false;
@Column(name = "enabled", nullable = false)
private Boolean enabled = true;
@@ -94,14 +91,6 @@ public class User {
this.avatarUrl = avatarUrl;
}
public Boolean getEmailVerified() {
return emailVerified;
}
public void setEmailVerified(Boolean emailVerified) {
this.emailVerified = emailVerified;
}
public Boolean getEnabled() {
return enabled;
}
@@ -160,7 +149,6 @@ public class User {
private String email;
private String nickname;
private String avatarUrl;
private Boolean emailVerified;
private Boolean enabled;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -191,11 +179,6 @@ public class User {
return this;
}
public Builder emailVerified(Boolean emailVerified) {
this.emailVerified = emailVerified;
return this;
}
public Builder enabled(Boolean enabled) {
this.enabled = enabled;
return this;
@@ -224,7 +207,6 @@ public class User {
user.nickname = this.nickname == null || this.nickname.isBlank()
? this.username : this.nickname;
user.avatarUrl = this.avatarUrl;
user.emailVerified = this.emailVerified != null && this.emailVerified;
user.enabled = this.enabled == null || this.enabled;
user.createdAt = this.createdAt;
user.updatedAt = this.updatedAt;
@@ -0,0 +1,11 @@
package com.onixbyte.deltaforceguide.exeption;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
public class ConflictException extends ResponseStatusException {
public ConflictException(String reason) {
super(HttpStatus.CONFLICT, reason);
}
}
@@ -1,7 +0,0 @@
package com.onixbyte.deltaforceguide.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface BaseRepository<T, K> extends JpaRepository<T, K>, JpaSpecificationExecutor<T> {
}
@@ -5,6 +5,7 @@ import com.onixbyte.deltaforceguide.enumeration.FirearmType;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
import java.util.List;
@@ -15,7 +16,7 @@ import java.util.List;
* @author zihluwang
*/
@Repository
public interface FirearmRepository extends BaseRepository<Firearm, Long> {
public interface FirearmRepository extends JpaRepository<Firearm, Long>, JpaSpecificationExecutor<Firearm> {
Page<Firearm> findAllByType(FirearmType type, Pageable pageable);
@@ -6,6 +6,7 @@ import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@@ -20,7 +21,7 @@ import java.util.Optional;
* @author zihluwang
*/
@Repository
public interface ModificationRepository extends BaseRepository<Modification, Long> {
public interface ModificationRepository extends JpaRepository<Modification, Long>, JpaSpecificationExecutor<Modification> {
@EntityGraph(attributePaths = {"firearm"})
Page<Modification> findAllBy(Pageable pageable);
@@ -4,6 +4,7 @@ import com.onixbyte.deltaforceguide.domain.entity.User;
import org.jspecify.annotations.NonNull;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@@ -16,7 +17,7 @@ import java.util.Optional;
* @author zihluwang
*/
@Repository
public interface UserRepository extends BaseRepository<User, Long> {
public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
@Override
@EntityGraph(attributePaths = {"credentials"})
@@ -2,10 +2,7 @@ package com.onixbyte.deltaforceguide.repository;
import com.onixbyte.deltaforceguide.domain.entity.UserRole;
import com.onixbyte.deltaforceguide.domain.entity.UserRoleId;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@@ -17,7 +14,7 @@ import java.util.List;
* @author zihluwang
*/
@Repository
public interface UserRoleRepository extends BaseRepository<UserRole, UserRoleId> {
public interface UserRoleRepository extends JpaRepository<UserRole, UserRoleId>, JpaSpecificationExecutor<UserRole> {
/**
* Find all roles assigned to a given user.
@@ -1,12 +1,16 @@
package com.onixbyte.deltaforceguide.service;
import com.onixbyte.captcha.text.TextProducer;
import com.onixbyte.deltaforceguide.client.EmailClient;
import com.onixbyte.deltaforceguide.client.TokenClient;
import com.onixbyte.deltaforceguide.domain.dto.LoginRequest;
import com.onixbyte.deltaforceguide.domain.dto.RegisterRequest;
import com.onixbyte.deltaforceguide.domain.dto.SendVerificationCodeRequest;
import com.onixbyte.deltaforceguide.domain.dto.UserResponse;
import com.onixbyte.deltaforceguide.domain.entity.User;
import com.onixbyte.deltaforceguide.domain.entity.UserCredential;
import com.onixbyte.deltaforceguide.exeption.BadRequestException;
import com.onixbyte.deltaforceguide.exeption.ConflictException;
import com.onixbyte.deltaforceguide.exeption.InternalServerErrorException;
import com.onixbyte.deltaforceguide.manager.CookieManager;
import com.onixbyte.deltaforceguide.manager.UserManager;
@@ -17,6 +21,8 @@ import com.onixbyte.deltaforceguide.shared.Role;
import org.jspecify.annotations.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.CacheManager;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpCookie;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
@@ -26,6 +32,7 @@ import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Service handling user authentication, login, and session management.
@@ -41,19 +48,31 @@ public class AuthService {
private final CookieManager cookieManager;
private final UserManager userManager;
private final PasswordEncoder passwordEncoder;
private final TextProducer textProducer;
private final CacheManager cacheManager;
private final RedisTemplate<String, Object> redisTemplate;
private final EmailClient emailClient;
public AuthService(
AuthenticationManager authenticationManager,
TokenClient tokenClient,
CookieManager cookieManager,
UserManager userManager,
PasswordEncoder passwordEncoder
PasswordEncoder passwordEncoder,
TextProducer textProducer,
CacheManager cacheManager,
RedisTemplate<String, Object> redisTemplate,
EmailClient emailClient
) {
this.authenticationManager = authenticationManager;
this.tokenClient = tokenClient;
this.cookieManager = cookieManager;
this.userManager = userManager;
this.passwordEncoder = passwordEncoder;
this.textProducer = textProducer;
this.cacheManager = cacheManager;
this.redisTemplate = redisTemplate;
this.emailClient = emailClient;
}
/**
@@ -96,6 +115,19 @@ public class AuthService {
throw new BadRequestException("该邮箱已被注册。");
}
var code = request.verificationCode();
var verificationCodeId = request.verificationCodeId();
var storedCode = redisTemplate.opsForValue().get("verification-code:" + verificationCodeId);
if (!(storedCode instanceof String strStoredCode)) {
log.error("storedCode not a String, storedCode={}", storedCode);
throw new InternalServerErrorException("系统内部错误,请重试");
}
if (!code.equalsIgnoreCase(strStoredCode)) {
throw new BadRequestException("邮箱验证码不正确,请重试。");
}
var now = LocalDateTime.now();
var user = User.builder()
.username(username)
@@ -129,4 +161,39 @@ public class AuthService {
public HttpCookie logout() {
return cookieManager.buildCookie(CookieName.ACCESS_TOKEN, "", Duration.ZERO);
}
/**
* Generates and dispatches a verification code to the given email address.
* <p>
* The code is cached under the returned UUID for 10 minutes and emailed
* asynchronously to the recipient. The UUID must be supplied back to the
* server when verifying the code.
*
* @param request the verification code request containing the target username and email
* @return the UUID identifying the verification record, to be used when verifying the code
* @throws ConflictException if the username or email is already registered
*/
public String sendVerificationCode(SendVerificationCodeRequest request) {
// Check username.
if (userManager.existsByUsername(request.username())) {
throw new ConflictException("用户名【" + request.username() + "】已被使用。");
}
// Check email address.
if (userManager.existsByEmail(request.email())) {
throw new ConflictException("邮箱地址【" + request.email() + "】已被使用。");
}
// Generate UUID and verification code.
var uuid = UUID.randomUUID().toString().replace("-", "");
var code = textProducer.getText();
// Save verification code to cache.
redisTemplate.opsForValue().set("verification-code:" + uuid, code, Duration.ofMinutes(10L));
// Send email.
emailClient.sendVerificationCode(request.email(), request.username(), code, 10);
return uuid;
}
}
@@ -196,10 +196,7 @@ public class UserService {
if (StringUtils.hasText(request.nickname())) {
user.setNickname(request.nickname());
}
if (StringUtils.hasText(request.email()) && !Objects.equals(user.getEmail(), request.email())) {
user.setEmail(request.email());
user.setEmailVerified(false);
}
userManager.save(user);
return getProfile(user);
}
+4
View File
@@ -30,6 +30,8 @@ spring:
flyway:
enabled: true
baseline-on-migrate: true
thymeleaf:
enabled: false
mybatis:
configuration:
@@ -40,6 +42,8 @@ mybatis:
mapper-locations: classpath:/mapper/*.xml
logging:
pattern:
dateformat: dd MMM yyyy HH:mm:ss
level:
org.hibernate:
orm.connections.pooling: off
@@ -1,7 +1,6 @@
ALTER TABLE app_user
ADD COLUMN nickname VARCHAR(64),
ADD COLUMN avatar_url VARCHAR(512),
ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="zh-CN">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>账号验证</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f1f5f9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f1f5f9; padding: 40px 16px;">
<tr>
<td align="center">
<!-- 主容器 -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 520px; background-color: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 12px rgba(15, 23, 42, 0.06); border: 1px solid #e2e8f0;">
<!-- 顶部标题 -->
<tr>
<td align="center" style="padding: 36px 32px 24px; background-color: #ffffff;">
<h1 style="margin: 0; font-size: 22px; font-weight: 700; color: #0f172a; letter-spacing: -0.3px;" th:text="${appName != null ? appName : '账号注册验证'}">账号注册验证</h1>
</td>
</tr>
<!-- 分割线 -->
<tr>
<td style="padding: 0 32px;">
<div style="height: 1px; background-color: #f1f5f9; width: 100%;"></div>
</td>
</tr>
<!-- 正文内容 -->
<tr>
<td style="padding: 32px 32px 24px;">
<p style="margin: 0 0 16px; font-size: 15px; line-height: 1.6; color: #334155;">
您好 <strong th:text="${username}">用户</strong>,
</p>
<p style="margin: 0 0 24px; font-size: 14px; line-height: 1.6; color: #64748b;">
感谢注册我们的服务。请使用下方的验证码完成账号验证,该验证码在 <strong style="color: #334155;"><span th:text="${expireMinutes}">10</span> 分钟</strong> 内有效:
</p>
<!-- 验证码展示区域 -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="margin: 8px 0 24px;">
<tr>
<td align="center" style="padding: 20px; background-color: #f8fafc; border: 1px dashed #cbd5e1; border-radius: 8px;">
<span style="font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 32px; font-weight: 700; color: #2563eb; letter-spacing: 8px; margin-left: 8px;" th:text="${code}">123456</span>
</td>
</tr>
</table>
<p style="margin: 0 0 12px; font-size: 13px; line-height: 1.6; color: #94a3b8;">
若您未发起此操作或未尝试注册该账号,请直接忽略本邮件。
</p>
</td>
</tr>
<!-- 页脚 -->
<tr>
<td align="center" style="padding: 24px 32px; background-color: #f8fafc; border-top: 1px solid #e2e8f0;">
<p style="margin: 0 0 6px; font-size: 12px; line-height: 1.4; color: #94a3b8;">
此邮件为系统自动发送,请勿直接回复。
</p>
<p style="margin: 0; font-size: 12px; color: #cbd5e1;">
&copy; <span th:text="${#calendars.year(#calendars.createNow())}">2026</span> <span>阿萨拉向导</span>. 保留所有权利。
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>