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

+18 -33
View File
@@ -24,43 +24,28 @@ java {
} }
dependencies { dependencies {
implementation(libs.jspecify.core) // BOMs are imported as platforms, so they cannot be bundled.
implementation(platform(libs.aws.sdk.bom)) implementation(platform(libs.aws.sdk.bom))
implementation(libs.aws.sdk.s3)
implementation(libs.commons.io)
implementation(libs.commons.lang)
implementation(libs.commons.collections)
implementation(libs.hypersistence.core)
implementation(platform(libs.onixbyte.versionCatalogue)) implementation(platform(libs.onixbyte.versionCatalogue))
implementation(libs.onixbyte.tuple)
implementation(libs.onixbyte.commonToolbox) implementation(libs.bundles.utilities)
implementation(libs.onixbyte.mathToolbox) implementation(libs.bundles.onixbyte)
implementation(libs.onixbyte.identityGenerator) implementation(libs.bundles.web)
implementation(libs.onixbyte.captcha) implementation(libs.bundles.persistence)
implementation(libs.onixbyte.regions) implementation(libs.bundles.security)
implementation(libs.onixbyte.cryptoToolbox) implementation(libs.bundles.cache)
implementation(libs.jwt.core) implementation(libs.bundles.observability)
implementation(libs.bundles.mail)
implementation(libs.bundles.aws)
implementation(libs.bundles.docs)
// Annotation processor for @ConfigurationProperties; kept out of bundles.
implementation(libs.spring.boot.configurationProcessor) implementation(libs.spring.boot.configurationProcessor)
implementation(libs.spring.boot.actuator)
implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.webFlux)
implementation(libs.spring.boot.starter.validation)
implementation(libs.spring.boot.starter.redis)
implementation(libs.spring.boot.starter.cache)
implementation(libs.spring.boot.starter.jpa)
implementation(libs.mybatis.starter.core)
implementation(libs.flyway.core)
implementation(libs.flyway.postgresql)
implementation(libs.jackson.jsr310)
implementation(libs.spring.boot.starter.doc)
implementation(libs.spring.boot.starter.security)
testImplementation(libs.spring.boot.starter.test)
testImplementation(libs.reactor.test)
testImplementation(libs.mybatis.starter.test)
testImplementation(libs.spring.security.test)
runtimeOnly(libs.postgres.driver) runtimeOnly(libs.postgres.driver)
testRuntimeOnly(libs.h2.database)
testRuntimeOnly(libs.junit.launcher) testImplementation(libs.bundles.testing)
testRuntimeOnly(libs.bundles.testRuntime)
} }
tasks.processResources { tasks.processResources {
+88
View File
@@ -59,6 +59,11 @@ spring-boot-actuator = { group = "org.springframework.boot", name = "spring-boot
spring-boot-starter-security = { group = "org.springframework.boot", name = "spring-boot-starter-security" } spring-boot-starter-security = { group = "org.springframework.boot", name = "spring-boot-starter-security" }
jwt-core = { group = "com.auth0", name = "java-jwt", version.ref = "javaJwtVersion" } jwt-core = { group = "com.auth0", name = "java-jwt", version.ref = "javaJwtVersion" }
# Java Mail & Templating
spring-boot-starter-mail = { module = "org.springframework.boot:spring-boot-starter-mail" }
spring-boot-starter-thymeleaf = { group = "org.springframework.boot", name = "spring-boot-starter-thymeleaf" }
thymeleaf-extras-springsecurity6 = { group = "org.thymeleaf.extras", name = "thymeleaf-extras-springsecurity6" }
# Spring Doc # Spring Doc
spring-boot-starter-doc = { group = "org.springdoc", name = "springdoc-openapi-starter-webmvc-ui", version.ref = "springDocVersion" } spring-boot-starter-doc = { group = "org.springdoc", name = "springdoc-openapi-starter-webmvc-ui", version.ref = "springDocVersion" }
@@ -73,6 +78,89 @@ reactor-test = { group = "io.projectreactor", name = "reactor-test", version.ref
junit-launcher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junitPlatformVersion" } junit-launcher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junitPlatformVersion" }
mybatis-starter-test = { group = "org.mybatis.spring.boot", name = "mybatis-spring-boot-starter-test", version.ref = "mybatisVersion" } mybatis-starter-test = { group = "org.mybatis.spring.boot", name = "mybatis-spring-boot-starter-test", version.ref = "mybatisVersion" }
[bundles]
# General utilities
utilities = [
"jspecify-core",
"commons-io",
"commons-lang",
"commons-collections",
"jackson-jsr310",
]
# Onixbyte ecosystem (the version-catalogue BOM stays out of bundles, it is imported as a platform)
onixbyte = [
"onixbyte-tuple",
"onixbyte-commonToolbox",
"onixbyte-cryptoToolbox",
"onixbyte-mathToolbox",
"onixbyte-identityGenerator",
"onixbyte-captcha",
"onixbyte-regions",
]
# Web layer
web = [
"spring-boot-starter-web",
"spring-boot-starter-webFlux",
"spring-boot-starter-validation",
]
# Persistence and database
persistence = [
"spring-boot-starter-jpa",
"mybatis-starter-core",
"hypersistence-core",
"flyway-core",
"flyway-postgresql",
]
# Security and authentication
security = [
"spring-boot-starter-security",
"jwt-core",
]
# Caching (Redis)
cache = [
"spring-boot-starter-redis",
"spring-boot-starter-cache",
]
# Observability
observability = [
"spring-boot-actuator",
]
# Mail and templating
mail = [
"spring-boot-starter-mail",
"spring-boot-starter-thymeleaf",
"thymeleaf-extras-springsecurity6",
]
# AWS cloud services
aws = [
"aws-sdk-s3",
]
# API documentation
docs = [
"spring-boot-starter-doc",
]
# Testing
testing = [
"spring-boot-starter-test",
"spring-security-test",
"reactor-test",
"mybatis-starter-test",
]
testRuntime = [
"h2-database",
"junit-launcher",
]
[plugins] [plugins]
spring-boot = { id = "org.springframework.boot", version.ref = "springBootVersion" } spring-boot = { id = "org.springframework.boot", version.ref = "springBootVersion" }
spring-dependency-management = { id = "io.spring.dependency-management", version.ref = "springDependencyManagementVersion" } spring-dependency-management = { id = "io.spring.dependency-management", version.ref = "springDependencyManagementVersion" }
@@ -2,12 +2,14 @@ package com.onixbyte.deltaforceguide;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
/** /**
* Entry point for the Delta Force Guide Server application. * Entry point for the Delta Force Guide Server application.
* *
* @author zihluwang * @author zihluwang
*/ */
@EnableAsync
@SpringBootApplication @SpringBootApplication
public class DeltaForceGuideApplication { 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; package com.onixbyte.deltaforceguide.controller;
import com.onixbyte.deltaforceguide.domain.dto.LoginRequest; import com.onixbyte.deltaforceguide.domain.dto.*;
import com.onixbyte.deltaforceguide.domain.dto.RegisterRequest;
import com.onixbyte.deltaforceguide.domain.dto.UserResponse;
import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth; import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth;
import com.onixbyte.deltaforceguide.service.AuthService; import com.onixbyte.deltaforceguide.service.AuthService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -48,6 +46,13 @@ public class AuthController {
.build(); .build();
} }
@Operation(description = "发送邮件验证码")
@PostMapping("/verification-code")
public SendVerificationCodeResponse sendVerificationCode(@Validated @RequestBody SendVerificationCodeRequest request) {
var verificationCodeId = authService.sendVerificationCode(request);
return new SendVerificationCodeResponse(verificationCodeId);
}
@Operation(description = "用户注册") @Operation(description = "用户注册")
@PostMapping("/register") @PostMapping("/register")
public ResponseEntity<UserResponse> register(@Validated @RequestBody RegisterRequest request) { public ResponseEntity<UserResponse> register(@Validated @RequestBody RegisterRequest request) {
@@ -24,6 +24,8 @@ public record RegisterRequest(
String avatarUrl, String avatarUrl,
@NotBlank(message = "密码不能为空") @NotBlank(message = "密码不能为空")
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED) @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( public record UpdateProfileRequest(
@Size(min = 1, max = 64, message = "昵称长度必须在1-64个字符之间") @Size(min = 1, max = 64, message = "昵称长度必须在1-64个字符之间")
String nickname, String nickname
@Email(message = "请输入正确的电子邮箱地址")
@Size(max = 255, message = "电子邮箱长度不能超过255个字符")
String email
) {} ) {}
@@ -16,7 +16,6 @@ public record UserProfileResponse(
String nickname, String nickname,
String email, String email,
String avatarUrl, String avatarUrl,
Boolean emailVerified,
Boolean enabled, Boolean enabled,
LocalDateTime createdAt, LocalDateTime createdAt,
List<String> roles List<String> roles
@@ -37,7 +36,6 @@ public record UserProfileResponse(
? user.getUsername() : user.getNickname(), ? user.getUsername() : user.getNickname(),
user.getEmail(), user.getEmail(),
user.getAvatarUrl(), user.getAvatarUrl(),
user.getEmailVerified(),
user.getEnabled(), user.getEnabled(),
user.getCreatedAt(), user.getCreatedAt(),
roles roles
@@ -39,9 +39,6 @@ public class User {
@Column(name = "avatar_url") @Column(name = "avatar_url")
private String avatarUrl; private String avatarUrl;
@Column(name = "email_verified", nullable = false)
private Boolean emailVerified = false;
@Column(name = "enabled", nullable = false) @Column(name = "enabled", nullable = false)
private Boolean enabled = true; private Boolean enabled = true;
@@ -94,14 +91,6 @@ public class User {
this.avatarUrl = avatarUrl; this.avatarUrl = avatarUrl;
} }
public Boolean getEmailVerified() {
return emailVerified;
}
public void setEmailVerified(Boolean emailVerified) {
this.emailVerified = emailVerified;
}
public Boolean getEnabled() { public Boolean getEnabled() {
return enabled; return enabled;
} }
@@ -160,7 +149,6 @@ public class User {
private String email; private String email;
private String nickname; private String nickname;
private String avatarUrl; private String avatarUrl;
private Boolean emailVerified;
private Boolean enabled; private Boolean enabled;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
@@ -191,11 +179,6 @@ public class User {
return this; return this;
} }
public Builder emailVerified(Boolean emailVerified) {
this.emailVerified = emailVerified;
return this;
}
public Builder enabled(Boolean enabled) { public Builder enabled(Boolean enabled) {
this.enabled = enabled; this.enabled = enabled;
return this; return this;
@@ -224,7 +207,6 @@ public class User {
user.nickname = this.nickname == null || this.nickname.isBlank() user.nickname = this.nickname == null || this.nickname.isBlank()
? this.username : this.nickname; ? this.username : this.nickname;
user.avatarUrl = this.avatarUrl; user.avatarUrl = this.avatarUrl;
user.emailVerified = this.emailVerified != null && this.emailVerified;
user.enabled = this.enabled == null || this.enabled; user.enabled = this.enabled == null || this.enabled;
user.createdAt = this.createdAt; user.createdAt = this.createdAt;
user.updatedAt = this.updatedAt; 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.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
@@ -15,7 +16,7 @@ import java.util.List;
* @author zihluwang * @author zihluwang
*/ */
@Repository @Repository
public interface FirearmRepository extends BaseRepository<Firearm, Long> { public interface FirearmRepository extends JpaRepository<Firearm, Long>, JpaSpecificationExecutor<Firearm> {
Page<Firearm> findAllByType(FirearmType type, Pageable pageable); 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.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository; 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.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -20,7 +21,7 @@ import java.util.Optional;
* @author zihluwang * @author zihluwang
*/ */
@Repository @Repository
public interface ModificationRepository extends BaseRepository<Modification, Long> { public interface ModificationRepository extends JpaRepository<Modification, Long>, JpaSpecificationExecutor<Modification> {
@EntityGraph(attributePaths = {"firearm"}) @EntityGraph(attributePaths = {"firearm"})
Page<Modification> findAllBy(Pageable pageable); Page<Modification> findAllBy(Pageable pageable);
@@ -4,6 +4,7 @@ import com.onixbyte.deltaforceguide.domain.entity.User;
import org.jspecify.annotations.NonNull; import org.jspecify.annotations.NonNull;
import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository; 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.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -16,7 +17,7 @@ import java.util.Optional;
* @author zihluwang * @author zihluwang
*/ */
@Repository @Repository
public interface UserRepository extends BaseRepository<User, Long> { public interface UserRepository extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
@Override @Override
@EntityGraph(attributePaths = {"credentials"}) @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.UserRole;
import com.onixbyte.deltaforceguide.domain.entity.UserRoleId; import com.onixbyte.deltaforceguide.domain.entity.UserRoleId;
import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.*;
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.repository.query.Param; import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -17,7 +14,7 @@ import java.util.List;
* @author zihluwang * @author zihluwang
*/ */
@Repository @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. * Find all roles assigned to a given user.
@@ -1,12 +1,16 @@
package com.onixbyte.deltaforceguide.service; 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.client.TokenClient;
import com.onixbyte.deltaforceguide.domain.dto.LoginRequest; import com.onixbyte.deltaforceguide.domain.dto.LoginRequest;
import com.onixbyte.deltaforceguide.domain.dto.RegisterRequest; 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.dto.UserResponse;
import com.onixbyte.deltaforceguide.domain.entity.User; import com.onixbyte.deltaforceguide.domain.entity.User;
import com.onixbyte.deltaforceguide.domain.entity.UserCredential; import com.onixbyte.deltaforceguide.domain.entity.UserCredential;
import com.onixbyte.deltaforceguide.exeption.BadRequestException; import com.onixbyte.deltaforceguide.exeption.BadRequestException;
import com.onixbyte.deltaforceguide.exeption.ConflictException;
import com.onixbyte.deltaforceguide.exeption.InternalServerErrorException; import com.onixbyte.deltaforceguide.exeption.InternalServerErrorException;
import com.onixbyte.deltaforceguide.manager.CookieManager; import com.onixbyte.deltaforceguide.manager.CookieManager;
import com.onixbyte.deltaforceguide.manager.UserManager; import com.onixbyte.deltaforceguide.manager.UserManager;
@@ -17,6 +21,8 @@ import com.onixbyte.deltaforceguide.shared.Role;
import org.jspecify.annotations.NonNull; import org.jspecify.annotations.NonNull;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; 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.HttpCookie;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -26,6 +32,7 @@ import org.springframework.stereotype.Service;
import java.time.Duration; import java.time.Duration;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID;
/** /**
* Service handling user authentication, login, and session management. * Service handling user authentication, login, and session management.
@@ -41,19 +48,31 @@ public class AuthService {
private final CookieManager cookieManager; private final CookieManager cookieManager;
private final UserManager userManager; private final UserManager userManager;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final TextProducer textProducer;
private final CacheManager cacheManager;
private final RedisTemplate<String, Object> redisTemplate;
private final EmailClient emailClient;
public AuthService( public AuthService(
AuthenticationManager authenticationManager, AuthenticationManager authenticationManager,
TokenClient tokenClient, TokenClient tokenClient,
CookieManager cookieManager, CookieManager cookieManager,
UserManager userManager, UserManager userManager,
PasswordEncoder passwordEncoder PasswordEncoder passwordEncoder,
TextProducer textProducer,
CacheManager cacheManager,
RedisTemplate<String, Object> redisTemplate,
EmailClient emailClient
) { ) {
this.authenticationManager = authenticationManager; this.authenticationManager = authenticationManager;
this.tokenClient = tokenClient; this.tokenClient = tokenClient;
this.cookieManager = cookieManager; this.cookieManager = cookieManager;
this.userManager = userManager; this.userManager = userManager;
this.passwordEncoder = passwordEncoder; 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("该邮箱已被注册。"); 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 now = LocalDateTime.now();
var user = User.builder() var user = User.builder()
.username(username) .username(username)
@@ -129,4 +161,39 @@ public class AuthService {
public HttpCookie logout() { public HttpCookie logout() {
return cookieManager.buildCookie(CookieName.ACCESS_TOKEN, "", Duration.ZERO); 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())) { if (StringUtils.hasText(request.nickname())) {
user.setNickname(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); userManager.save(user);
return getProfile(user); return getProfile(user);
} }
+4
View File
@@ -30,6 +30,8 @@ spring:
flyway: flyway:
enabled: true enabled: true
baseline-on-migrate: true baseline-on-migrate: true
thymeleaf:
enabled: false
mybatis: mybatis:
configuration: configuration:
@@ -40,6 +42,8 @@ mybatis:
mapper-locations: classpath:/mapper/*.xml mapper-locations: classpath:/mapper/*.xml
logging: logging:
pattern:
dateformat: dd MMM yyyy HH:mm:ss
level: level:
org.hibernate: org.hibernate:
orm.connections.pooling: off orm.connections.pooling: off
@@ -1,7 +1,6 @@
ALTER TABLE app_user ALTER TABLE app_user
ADD COLUMN nickname VARCHAR(64), ADD COLUMN nickname VARCHAR(64),
ADD COLUMN avatar_url VARCHAR(512), 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 enabled BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ADD COLUMN updated_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>