From a694938146218e8d07fca4095ecf8eaf4a46ad49 Mon Sep 17 00:00:00 2001 From: damon Date: Sun, 24 May 2026 00:52:25 +0800 Subject: [PATCH 1/5] Add authentication and user management controllers with token grant DTOs --- .../controller/AccountController.java | 10 +++++ .../interfaces/controller/AuthController.java | 45 +++++++++++++++++++ .../interfaces/controller/UserController.java | 9 ++++ .../interfaces/dto/TokenGrantRequestDto.java | 11 +++++ .../interfaces/dto/TokenGrantResponseDto.java | 16 +++++++ 5 files changed, 91 insertions(+) create mode 100644 identity/src/main/java/io/theurl/identity/interfaces/controller/AccountController.java create mode 100644 identity/src/main/java/io/theurl/identity/interfaces/controller/AuthController.java create mode 100644 identity/src/main/java/io/theurl/identity/interfaces/controller/UserController.java create mode 100644 identity/src/main/java/io/theurl/identity/interfaces/dto/TokenGrantRequestDto.java create mode 100644 identity/src/main/java/io/theurl/identity/interfaces/dto/TokenGrantResponseDto.java diff --git a/identity/src/main/java/io/theurl/identity/interfaces/controller/AccountController.java b/identity/src/main/java/io/theurl/identity/interfaces/controller/AccountController.java new file mode 100644 index 0000000..4dad31b --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/interfaces/controller/AccountController.java @@ -0,0 +1,10 @@ +package io.theurl.identity.interfaces.controller; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("account") +public class AccountController { + +} diff --git a/identity/src/main/java/io/theurl/identity/interfaces/controller/AuthController.java b/identity/src/main/java/io/theurl/identity/interfaces/controller/AuthController.java new file mode 100644 index 0000000..d073b6e --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/interfaces/controller/AuthController.java @@ -0,0 +1,45 @@ +package io.theurl.identity.interfaces.controller; + +import io.theurl.identity.application.contract.AuthApplicationService; +import io.theurl.identity.interfaces.dto.TokenGrantRequestDto; +import io.theurl.identity.interfaces.dto.TokenGrantResponseDto; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("auth") +public class AuthController { + private final AuthApplicationService service; + + public AuthController(AuthApplicationService service) { + this.service = service; + } + + /** + * Grant an access token based on the provided credentials. + * This endpoint allows clients to obtain an access token by providing valid credentials, such as username and password. + * The server will validate the credentials and, if they are correct, will issue an access token along with a refresh token. + * The access token can be used for authentication and authorization in subsequent requests, + * while the refresh token can be used to obtain a new access token when the current one expires. + * + * @param request The request containing the user's credentials. + * @return A response containing the access token and refresh token. + */ + @PostMapping("token/grant") + public TokenGrantResponseDto grantToken(@RequestBody TokenGrantRequestDto request) { + return service.grant(request).join(); + } + + /** + * Refresh the access token using the provided refresh token. + * This endpoint allows clients to obtain a new access token when the current one expires, without requiring the user to re-authenticate. + * The client must provide a valid refresh token, and if it is valid, a new access token will be issued along with a new refresh token. + * + * @param token The refresh token used to obtain a new access token. + * @return A response containing the new access token and refresh token. + */ + @PostMapping("token/refresh") + public TokenGrantResponseDto refreshToken(@RequestParam String token) { + var request = new TokenGrantRequestDto(token, null, "refresh_token", null); + return service.grant(request).join(); + } +} diff --git a/identity/src/main/java/io/theurl/identity/interfaces/controller/UserController.java b/identity/src/main/java/io/theurl/identity/interfaces/controller/UserController.java new file mode 100644 index 0000000..3a7cea2 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/interfaces/controller/UserController.java @@ -0,0 +1,9 @@ +package io.theurl.identity.interfaces.controller; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("user") +public class UserController { +} diff --git a/identity/src/main/java/io/theurl/identity/interfaces/dto/TokenGrantRequestDto.java b/identity/src/main/java/io/theurl/identity/interfaces/dto/TokenGrantRequestDto.java new file mode 100644 index 0000000..7d33c44 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/interfaces/dto/TokenGrantRequestDto.java @@ -0,0 +1,11 @@ +package io.theurl.identity.interfaces.dto; + +/** + * DTO for token grant request + * @param username the identifier of the user, maybe email, phone number, unique username, etc. + * @param password the password of the user + * @param grantType the type of grant, available values: Username/Email/Phone/Github/Microsoft/Google. + * @param requestId the request ID for the authentication request. + */ +public record TokenGrantRequestDto(String username, String password, String grantType, String requestId) { +} diff --git a/identity/src/main/java/io/theurl/identity/interfaces/dto/TokenGrantResponseDto.java b/identity/src/main/java/io/theurl/identity/interfaces/dto/TokenGrantResponseDto.java new file mode 100644 index 0000000..8a1c4df --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/interfaces/dto/TokenGrantResponseDto.java @@ -0,0 +1,16 @@ +package io.theurl.identity.interfaces.dto; + +/** + * DTO for token grant response + * + * @param accessToken the access token for the user, used for authentication and authorization in subsequent requests. + * @param refreshToken the refresh token for the user, used to obtain a new access token when the current one expires. + * @param tokenType the type of the token, typically "Bearer". + * @param expiresIn the duration in seconds for which the access token is valid. + * @param issueAt the timestamp when the token was issued. + * @param username the username of the authenticated user. + * @param userId the unique identifier of the authenticated user. + */ +public record TokenGrantResponseDto(String accessToken, String refreshToken, String tokenType, long expiresIn, + long issueAt, String username, Long userId) { +} From b568563f9c2e4d7f558d8cbac48940b0289fc5fc Mon Sep 17 00:00:00 2001 From: damon Date: Sun, 24 May 2026 00:52:33 +0800 Subject: [PATCH 2/5] Add InterfacesApplicationContext configuration class --- .../identity/interfaces/InterfacesApplicationContext.java | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 identity/src/main/java/io/theurl/identity/interfaces/InterfacesApplicationContext.java diff --git a/identity/src/main/java/io/theurl/identity/interfaces/InterfacesApplicationContext.java b/identity/src/main/java/io/theurl/identity/interfaces/InterfacesApplicationContext.java new file mode 100644 index 0000000..5879b1c --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/interfaces/InterfacesApplicationContext.java @@ -0,0 +1,8 @@ +package io.theurl.identity.interfaces; + +import org.springframework.context.annotation.Configuration; + +@Configuration +public class InterfacesApplicationContext { + +} From a644412fb2e5afab4c83ca5c471f713aa19fd682 Mon Sep 17 00:00:00 2001 From: damon Date: Sun, 24 May 2026 00:54:10 +0800 Subject: [PATCH 3/5] Add AuthApplicationService and implementation for token grant handling --- .../contract/AuthApplicationService.java | 10 + .../event/UserAuthFailureEvent.java | 20 ++ .../event/UserAuthSuccessEvent.java | 26 +++ .../implement/AuthApplicationServiceImpl.java | 199 ++++++++++++++++++ .../configure/MediatorConfiguration.java | 21 -- 5 files changed, 255 insertions(+), 21 deletions(-) create mode 100644 identity/src/main/java/io/theurl/identity/application/contract/AuthApplicationService.java create mode 100644 identity/src/main/java/io/theurl/identity/application/event/UserAuthFailureEvent.java create mode 100644 identity/src/main/java/io/theurl/identity/application/event/UserAuthSuccessEvent.java create mode 100644 identity/src/main/java/io/theurl/identity/application/implement/AuthApplicationServiceImpl.java delete mode 100644 identity/src/main/java/io/theurl/identity/configure/MediatorConfiguration.java diff --git a/identity/src/main/java/io/theurl/identity/application/contract/AuthApplicationService.java b/identity/src/main/java/io/theurl/identity/application/contract/AuthApplicationService.java new file mode 100644 index 0000000..2ad8f9e --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/contract/AuthApplicationService.java @@ -0,0 +1,10 @@ +package io.theurl.identity.application.contract; + +import io.theurl.identity.interfaces.dto.TokenGrantRequestDto; +import io.theurl.identity.interfaces.dto.TokenGrantResponseDto; + +import java.util.concurrent.CompletableFuture; + +public interface AuthApplicationService { + CompletableFuture grant(TokenGrantRequestDto request); +} diff --git a/identity/src/main/java/io/theurl/identity/application/event/UserAuthFailureEvent.java b/identity/src/main/java/io/theurl/identity/application/event/UserAuthFailureEvent.java new file mode 100644 index 0000000..6604bed --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/event/UserAuthFailureEvent.java @@ -0,0 +1,20 @@ +package io.theurl.identity.application.event; + +import com.neroyun.mediator.Event; +import io.theurl.framework.domain.ApplicationEvent; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; +import java.util.Map; + +@EqualsAndHashCode(callSuper = true) +@Data +public class UserAuthFailureEvent extends ApplicationEvent implements Event { + private String userId; + private String username; + private String grantType; + private LocalDateTime grantTime; + private Map data; + private String error; +} diff --git a/identity/src/main/java/io/theurl/identity/application/event/UserAuthSuccessEvent.java b/identity/src/main/java/io/theurl/identity/application/event/UserAuthSuccessEvent.java new file mode 100644 index 0000000..2b5c928 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/event/UserAuthSuccessEvent.java @@ -0,0 +1,26 @@ +package io.theurl.identity.application.event; + +import com.neroyun.mediator.Event; +import io.theurl.framework.domain.ApplicationEvent; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; +import java.util.Map; + +@EqualsAndHashCode(callSuper = true) +@Data +public final class UserAuthSuccessEvent extends ApplicationEvent implements Event { + private final String grantType; + private final Long userId; + + + public UserAuthSuccessEvent(String grantType, Long userId) { + this.grantType = grantType; + this.userId = userId; + } + + private String username; + private LocalDateTime grantTime; + private Map data; +} diff --git a/identity/src/main/java/io/theurl/identity/application/implement/AuthApplicationServiceImpl.java b/identity/src/main/java/io/theurl/identity/application/implement/AuthApplicationServiceImpl.java new file mode 100644 index 0000000..ba16aa0 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/application/implement/AuthApplicationServiceImpl.java @@ -0,0 +1,199 @@ +package io.theurl.identity.application.implement; + +import com.neroyun.mediator.Event; +import com.neroyun.mediator.internal.AggregateException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import io.theurl.framework.application.BaseApplicationService; +import io.theurl.framework.core.ObjectId; +import io.theurl.framework.security.*; +import io.theurl.framework.utility.Cryptography; +import io.theurl.identity.application.contract.AuthApplicationService; +import io.theurl.identity.application.event.UserAuthFailureEvent; +import io.theurl.identity.application.event.UserAuthSuccessEvent; +import io.theurl.identity.external.ExternalAuthProvider; +import io.theurl.identity.external.ExternalAuthResult; +import io.theurl.identity.interfaces.dto.TokenGrantRequestDto; +import io.theurl.identity.interfaces.dto.TokenGrantResponseDto; +import io.theurl.identity.persistence.model.UserAuthInfo; +import io.theurl.identity.persistence.query.OnetimePasswordDetailQuery; +import io.theurl.identity.persistence.query.UserAuthInfoQuery; +import jakarta.persistence.EntityNotFoundException; +import jakarta.persistence.NoResultException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Service; +import org.springframework.util.Assert; +import org.springframework.web.context.annotation.RequestScope; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +@Slf4j +@RequestScope +@Service +public class AuthApplicationServiceImpl extends BaseApplicationService implements AuthApplicationService { + + @Value("${jwt.secret}") + private String signingKey; + + @Value("${jwt.issuer}") + private String issuer; + + private final ApplicationContext applicationContext; + + @Autowired + public AuthApplicationServiceImpl(ApplicationContext applicationContext) { + super(applicationContext); + this.applicationContext = applicationContext; + } + + @Override + public CompletableFuture grant(TokenGrantRequestDto request) { + return CompletableFuture.supplyAsync(() -> { + var events = new ArrayList(); + try { + + UserAuthInfoQuery query = switch (request.grantType().toLowerCase()) { + case null -> throw new IllegalArgumentException("Grant type is required"); + case "" -> throw new IllegalArgumentException("Grant type is required"); + case "password" -> { + if (request.username() == null || request.username().isEmpty()) { + throw new IllegalArgumentException("Username is required for username grant type"); + } + yield new UserAuthInfoQuery("username", request.username()); + } + case "email", "phone" -> + // For email and phone grant types, we should check OTP or other verification methods before querying user info. + checkCodeAsync(request).thenApply(_ -> new UserAuthInfoQuery(request.grantType(), request.username())).join(); + case "github", "microsoft", "google", "facebook" -> + authWithExternalAsync(request.grantType(), request.username()).thenApply(userId -> new UserAuthInfoQuery(request.grantType(), userId)).join(); + default -> throw new IllegalArgumentException("Unsupported grant type: " + request.grantType()); + }; + + var userInfo = mediator.executeAsync(query).join(); + + if (userInfo == null) { + throw new CredentialNotFoundException(request.username(), "Invalid username or password."); + } + + if (userInfo.getLockedUntil().isAfter(LocalDateTime.now())) { + throw new AccountLockedException(request.username(), "Account is locked until " + userInfo.getLockedUntil()); + } + + if (request.grantType().equals("password")) { + var passwordHash = Cryptography.AES.encrypt(request.password(), userInfo.getPasswordSalt()); + if (!passwordHash.equals(userInfo.getPasswordHash())) { + throw new CredentialIncorrectException("Invalid username or password."); + } + } + + events.add(new UserAuthSuccessEvent(request.grantType(), userInfo.getId())); + + var jwtId = ObjectId.guid().toString(); + var iat = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC); + var exp = LocalDateTime.now().plusHours(24).toEpochSecond(ZoneOffset.UTC); + var accessToken = generateToken(jwtId, userInfo, iat, exp); + + return new TokenGrantResponseDto(accessToken, jwtId, "Bearer", 3600 * 24, iat, userInfo.getUsername(), userInfo.getId()); + + } catch (Exception e) { + var event = new UserAuthFailureEvent(); + + handleException(e, ex -> { + switch (ex) { + case AccountLockedException exception: + event.setUserId(exception.getIdentity()); + event.setGrantType(request.grantType()); + event.setGrantTime(LocalDateTime.now()); + event.setError(exception.getLocalizedMessage()); + event.setData(Map.of("username", request.username() != null ? request.username() : "", "password", request.password(), "locked", "true")); + break; + case NoResultException ignored: + event.setUsername(request.username()); + event.setGrantType(request.grantType()); + event.setGrantTime(LocalDateTime.now()); + event.setError(ex.getLocalizedMessage()); + event.setData(Map.of("username", request.username())); + break; + case EntityNotFoundException ignored: + event.setUsername(request.username()); + event.setGrantType(request.grantType()); + event.setGrantTime(LocalDateTime.now()); + event.setError(ex.getLocalizedMessage()); + event.setData(Map.of("username", request.username())); + break; + case AccountNotFoundException ignored: + event.setUsername(request.username()); + event.setGrantType(request.grantType()); + event.setGrantTime(LocalDateTime.now()); + event.setError(ex.getLocalizedMessage()); + event.setData(Map.of("username", request.username())); + break; + case CredentialException exception: + event.setUsername(request.username()); + event.setGrantType(request.grantType()); + event.setGrantTime(LocalDateTime.now()); + event.setError(exception.getLocalizedMessage()); + event.setData(Map.of("username", request.username(), "password", request.password())); + break; + default: + break; + } + }); + log.error("Error while processing request", e); + throw new AggregateException(List.of(e)); + } finally { + if (!events.isEmpty()) { + events.parallelStream().forEach(mediator::publishAsync); + } + } + }, mediatorTaskExecutor.getThreadPoolExecutor()); + } + + CompletableFuture checkCodeAsync(TokenGrantRequestDto request) { + return mediator.executeAsync(new OnetimePasswordDetailQuery(request.requestId())).thenAccept(otp -> { + if (otp == null) { + throw new CredentialIncorrectException("Invalid verify code."); + } + if (otp.getExpiration().isBefore(LocalDateTime.now())) { + throw new CredentialIncorrectException("Verify code has expired."); + } + if (otp.getChecked() != null) { + throw new CredentialIncorrectException("Verify code has already been used."); + } + if (!otp.getRecipient().equals(request.username())) { + throw new CredentialIncorrectException("Invalid verify code recipient."); + } + if (otp.getCode() == null || !otp.getCode().equals(request.password())) { + throw new CredentialIncorrectException("Invalid verify code."); + } + }); + } + + CompletableFuture authWithExternalAsync(String grantType, String username) { + var provider = applicationContext.getBean(("external-auth-provider-" + grantType).toLowerCase(), ExternalAuthProvider.class); + // Here we should check if the external auth result is linked to an internal user account, and return the user ID. + // For simplicity, we just return a dummy user ID. + return provider.authenticateAsync(username).thenApply(ExternalAuthResult::getId); + } + + private String generateToken(String id, UserAuthInfo user, long issuedAt, long expiresAt) { + Assert.notNull(user, "user cannot be null"); + //var signingKey = environment.getProperty("JwtAuthenticationOptions.SigningKey"); + Assert.notNull(signingKey, "SigningKey cannot be null"); + + var builder = Jwts.builder(); + builder.subject(String.valueOf(user.getId())).id(id).issuer(issuer).issuedAt(new Date(issuedAt)).expiration(new Date(expiresAt)) // 24小时后过期 + .claim("name", user.getUsername()); + builder.signWith(Keys.hmacShaKeyFor(signingKey.getBytes())); + return builder.compact(); + } +} diff --git a/identity/src/main/java/io/theurl/identity/configure/MediatorConfiguration.java b/identity/src/main/java/io/theurl/identity/configure/MediatorConfiguration.java deleted file mode 100644 index 4877e02..0000000 --- a/identity/src/main/java/io/theurl/identity/configure/MediatorConfiguration.java +++ /dev/null @@ -1,21 +0,0 @@ -package io.theurl.identity.configure; - -import com.neroyun.mediator.*; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import java.util.concurrent.Executors; - -@Configuration -public class MediatorConfiguration { - @Bean - public Mediator mediator(ObjectProvider handlers, ObjectProvider middlewares, ObjectProvider validators) { - return new PipelinedMediator() - .use(() -> handlers.stream()) - .use(() -> middlewares.stream()) - .use(() -> validators.stream()) - .use(Executors::newVirtualThreadPerTaskExecutor); - } - -} From b47d5f6922852a0b392fec92262a30b7c6bfd6b7 Mon Sep 17 00:00:00 2001 From: damon Date: Sun, 24 May 2026 00:54:22 +0800 Subject: [PATCH 4/5] Add entity classes for authentication logging, one-time passwords, tokens, users, user authorities, and user roles --- .../identity/persistence/entity/Authlog.java | 72 +++++++++++++++++++ .../persistence/entity/OnetimePassword.java | 52 ++++++++++++++ .../identity/persistence/entity/Token.java | 43 +++++++++++ .../identity/persistence/entity/User.java | 66 +++++++++++++++++ .../persistence/entity/UserAuthority.java | 42 +++++++++++ .../identity/persistence/entity/UserRole.java | 37 ++++++++++ 6 files changed, 312 insertions(+) create mode 100644 identity/src/main/java/io/theurl/identity/persistence/entity/Authlog.java create mode 100644 identity/src/main/java/io/theurl/identity/persistence/entity/OnetimePassword.java create mode 100644 identity/src/main/java/io/theurl/identity/persistence/entity/Token.java create mode 100644 identity/src/main/java/io/theurl/identity/persistence/entity/User.java create mode 100644 identity/src/main/java/io/theurl/identity/persistence/entity/UserAuthority.java create mode 100644 identity/src/main/java/io/theurl/identity/persistence/entity/UserRole.java diff --git a/identity/src/main/java/io/theurl/identity/persistence/entity/Authlog.java b/identity/src/main/java/io/theurl/identity/persistence/entity/Authlog.java new file mode 100644 index 0000000..61f5e3f --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/entity/Authlog.java @@ -0,0 +1,72 @@ +package io.theurl.identity.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Data; +import org.springframework.data.domain.Persistable; + +import java.time.LocalDateTime; + +@Data +@Entity +@Table(name = "authlog") +public class Authlog implements Persistable { + @Id + private Long id; + + @Column(name = "user_id") + private Long userId; + + @Column(length = User.USERNAME_LENGTH) + private String username; + + @Column(name = "grant_type", length = 32) + private String grantType; + + @Column(name = "request_id", length = 64) + private String requestId; + + @Column(name = "ip_address", length = 15) + private String ipAddress; + + @Column(name = "user_agent") + private String userAgent; + + @Column + private String referrer; + + @Column(name = "app_name", length = 64) + private String appName; + + @Column(name = "app_version", length = 20) + private String appVersion; + + @Column(name = "os_platform", length = 32) + private String osPlatform; + + @Column(length = 32) + private String source; + + @Column + private boolean success; + + @Column(length = 500) + private String remark; + + @Column + private LocalDateTime timestamp; + + @Override + public Long getId() { + return id; + } + + @Override + public boolean isNew() { + return false; + } + + +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/entity/OnetimePassword.java b/identity/src/main/java/io/theurl/identity/persistence/entity/OnetimePassword.java new file mode 100644 index 0000000..9179bda --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/entity/OnetimePassword.java @@ -0,0 +1,52 @@ +package io.theurl.identity.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Data; +import org.springframework.data.domain.Persistable; + +import java.time.LocalDateTime; + +@Data +@Entity +@Table(name = "onetime_password") +public class OnetimePassword implements Persistable { + @Id + private Long id; + + @Column(name = "request_id", nullable = false, unique = true, length = 64) + private String requestId; + + @Column(name = "code", nullable = false, length = 10) + private String code; + + @Column(name = "recipient", nullable = false) + private String recipient; + + @Column(name = "expiration") + private LocalDateTime expiration; + + @Column(name = "checked") + private LocalDateTime checked; + + @Column(name = "duration") + private Integer duration; + + @Column(name = "usage") + private int usage; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Override + public Long getId() { + return id; + } + + @Override + public boolean isNew() { + return id == null; + } +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/entity/Token.java b/identity/src/main/java/io/theurl/identity/persistence/entity/Token.java new file mode 100644 index 0000000..4480d7a --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/entity/Token.java @@ -0,0 +1,43 @@ +package io.theurl.identity.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Data; +import org.springframework.data.domain.Persistable; + +import java.time.LocalDateTime; + +@Data +@Entity +@Table(name = "token") +public class Token implements Persistable { + @Id + private Long id; + + @Column(length = 32) + private String type; + + @Column(length = 256) + private String key; + + @Column + private Long subject; + + @Column(name = "issued_at") + private LocalDateTime issuedAt; + + @Column(name = "expires_at") + private LocalDateTime expiresAt; + + @Override + public Long getId() { + return id; + } + + @Override + public boolean isNew() { + return false; + } +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/entity/User.java b/identity/src/main/java/io/theurl/identity/persistence/entity/User.java new file mode 100644 index 0000000..d9490d9 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/entity/User.java @@ -0,0 +1,66 @@ +package io.theurl.identity.persistence.entity; + +import jakarta.persistence.*; +import lombok.Data; +import org.springframework.data.domain.Persistable; + +import java.time.LocalDateTime; + +@Data +@Entity +@Table(name = "users", indexes = { + @Index(name = "user_idx_username", columnList = "username", unique = true), + @Index(name = "user_idx_email", columnList = "email", unique = true), + @Index(name = "user_idx_phone", columnList = "phone", unique = true) +}) +public class User implements Persistable { + static final int USERNAME_LENGTH = 64; + static final int PASSWORD_HASH_LENGTH = 1000; + static final int PASSWORD_SALT_LENGTH = 32; + + @Id + private Long id; + + @Column(unique = true, nullable = false, updatable = false, length = USERNAME_LENGTH) + private String username; + + @Column(nullable = false, name = "password_hash", length = PASSWORD_HASH_LENGTH) + private String passwordHash; + + @Column(nullable = false, name = "password_salt", length = PASSWORD_SALT_LENGTH) + private String passwordSalt; + + @Column(length = 64, updatable = false) + private String nickname; + + @Column(unique = true) + private String email; + + @Column(unique = true, length = 25) + private String phone; + + @Column(name = "access_failed_count", columnDefinition = "int default 0") + private Integer accessFailedCount = 0; + + @Column(name = "locked_until") + private LocalDateTime lockedUntil; + + @Column(name = "password_changed_at") + private LocalDateTime passwordChangedAt; + + @Column(name = "created_at") + private LocalDateTime createdAt = LocalDateTime.now(); + + @Column(name = "updated_at") + private LocalDateTime updatedAt = LocalDateTime.now(); + + @Override + public Long getId() { + return id; + } + + @Override + public boolean isNew() { + return false; + } +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/entity/UserAuthority.java b/identity/src/main/java/io/theurl/identity/persistence/entity/UserAuthority.java new file mode 100644 index 0000000..2432820 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/entity/UserAuthority.java @@ -0,0 +1,42 @@ +package io.theurl.identity.persistence.entity; + +import jakarta.persistence.*; +import lombok.Data; +import org.springframework.data.domain.Persistable; + +import java.time.LocalDateTime; + +@Data +@Entity +@Table(name = "user_authority", indexes = { + @Index(name = "user_authority_idx_unique", columnList = "provider,open_id,user_id", unique = true) +}) +public class UserAuthority implements Persistable { + @Id + private Long id; + + @Column(name = "user_id") + private Long userId; + + @Column(length = 100) + private String provider; + + @Column(length = 200) + private String openId; + + @Column(length = 100) + private String name; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Override + public Long getId() { + return id; + } + + @Override + public boolean isNew() { + return false; + } +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/entity/UserRole.java b/identity/src/main/java/io/theurl/identity/persistence/entity/UserRole.java new file mode 100644 index 0000000..ae0f332 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/entity/UserRole.java @@ -0,0 +1,37 @@ +package io.theurl.identity.persistence.entity; + +import jakarta.persistence.*; +import lombok.Data; +import org.springframework.data.domain.Persistable; + +import java.time.LocalDateTime; + +@Data +@Entity +@Table(name = "user_role", indexes = { + @Index(name = "user_role_idx_unique", columnList = "name,user_id", unique = true) +}) +public class UserRole implements Persistable { + @Id + @Column(length = 20) + private Long id; + + @Column(length = 100) + private String name; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Override + public Long getId() { + return id; + } + + @Override + public boolean isNew() { + return false; + } +} From 794daffc1ae7de07e5c1bd348759a1f26a53f215 Mon Sep 17 00:00:00 2001 From: damon Date: Sun, 24 May 2026 00:54:44 +0800 Subject: [PATCH 5/5] Add query handlers for onetime password details and user authentication info --- .../OnetimePasswordDetailQueryHandler.java | 35 ++++++ .../handler/UserAuthInfoQueryHandler.java | 106 ++++++++++++++++++ .../model/OnetimePasswordDetail.java | 15 +++ .../persistence/model/UserAuthInfo.java | 19 ++++ .../query/OnetimePasswordDetailQuery.java | 7 ++ .../persistence/query/UserAuthInfoQuery.java | 8 ++ 6 files changed, 190 insertions(+) create mode 100644 identity/src/main/java/io/theurl/identity/persistence/handler/OnetimePasswordDetailQueryHandler.java create mode 100644 identity/src/main/java/io/theurl/identity/persistence/handler/UserAuthInfoQueryHandler.java create mode 100644 identity/src/main/java/io/theurl/identity/persistence/model/OnetimePasswordDetail.java create mode 100644 identity/src/main/java/io/theurl/identity/persistence/model/UserAuthInfo.java create mode 100644 identity/src/main/java/io/theurl/identity/persistence/query/OnetimePasswordDetailQuery.java create mode 100644 identity/src/main/java/io/theurl/identity/persistence/query/UserAuthInfoQuery.java diff --git a/identity/src/main/java/io/theurl/identity/persistence/handler/OnetimePasswordDetailQueryHandler.java b/identity/src/main/java/io/theurl/identity/persistence/handler/OnetimePasswordDetailQueryHandler.java new file mode 100644 index 0000000..6685c79 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/handler/OnetimePasswordDetailQueryHandler.java @@ -0,0 +1,35 @@ +package io.theurl.identity.persistence.handler; + +import com.neroyun.mediator.Handler; +import io.theurl.identity.persistence.model.OnetimePasswordDetail; +import io.theurl.identity.persistence.query.OnetimePasswordDetailQuery; +import jakarta.persistence.EntityManager; +import org.springframework.context.annotation.Scope; +import org.springframework.context.annotation.ScopedProxyMode; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.web.context.WebApplicationContext; + +import java.util.concurrent.CompletableFuture; + +@Component +@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +public class OnetimePasswordDetailQueryHandler implements Handler { + + private final EntityManager context; + + public OnetimePasswordDetailQueryHandler(EntityManager context) { + this.context = context; + } + + @Override + @Async + public CompletableFuture handleAsync(OnetimePasswordDetailQuery message) { + var builder = context.getCriteriaBuilder(); + var criteria = builder.createQuery(OnetimePasswordDetail.class); + var entity = criteria.from(OnetimePasswordDetail.class); + criteria.where(builder.equal(entity.get("requestId"), message.requestId())); + var typedQuery = context.createQuery(criteria); + return CompletableFuture.completedFuture(typedQuery.getSingleResult()); + } +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/handler/UserAuthInfoQueryHandler.java b/identity/src/main/java/io/theurl/identity/persistence/handler/UserAuthInfoQueryHandler.java new file mode 100644 index 0000000..51abaad --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/handler/UserAuthInfoQueryHandler.java @@ -0,0 +1,106 @@ +package io.theurl.identity.persistence.handler; + +import com.neroyun.mediator.Handler; +import io.theurl.identity.persistence.entity.User; +import io.theurl.identity.persistence.entity.UserRole; +import io.theurl.identity.persistence.model.UserAuthInfo; +import io.theurl.identity.persistence.query.UserAuthInfoQuery; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityNotFoundException; +import jakarta.persistence.NoResultException; +import org.jspecify.annotations.NonNull; +import org.springframework.context.annotation.Scope; +import org.springframework.context.annotation.ScopedProxyMode; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.web.context.WebApplicationContext; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static java.util.concurrent.CompletableFuture.supplyAsync; + +@Component +@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS) +public class UserAuthInfoQueryHandler implements Handler { + + private final EntityManager entityManager; + + public UserAuthInfoQueryHandler(EntityManager entityManager) { + this.entityManager = entityManager; + } + + /** + * Handles the given query and returns the corresponding UserAuthInfo. + * + * @param query the message to be processed by this handler + * @return the UserAuthInfo corresponding to the given query + */ + @Override + @Async + public CompletableFuture handleAsync(UserAuthInfoQuery query) { + return supplyAsync(() -> { + String sql = getSql(query); + + var typedQuery = entityManager.createQuery(sql, User.class); + + switch (query.provider()) { + case "id" -> typedQuery.setParameter("id", Long.parseLong(query.name())); + case "email" -> typedQuery.setParameter("email", query.name()); + case "phone" -> typedQuery.setParameter("phone", query.name()); + case "username" -> typedQuery.setParameter("username", query.name()); + default -> { + typedQuery.setParameter("provider", query.provider()); + typedQuery.setParameter("name", query.name()); + } + } + + User user; + + try { + user = typedQuery.getSingleResult(); + } catch (NoResultException | EntityNotFoundException _) { + return null; + } + + if (user == null) { + return null; + } + + List roles = entityManager.createQuery("SELECT u from UserRole u where u.userId = :userId", UserRole.class).setParameter("userId", user.getId()).getResultList(); + + return new UserAuthInfo() {{ + setId(user.getId()); + setUsername(user.getUsername()); + setPasswordHash(user.getPasswordHash()); + setPasswordSalt(user.getPasswordSalt()); + setLockedUntil(user.getLockedUntil()); + setNickname(user.getNickname()); + setEmail(user.getEmail()); + setPhone(user.getPhone()); + setRoles(roles.stream().map(UserRole::getName).toList()); + }}; + }); + } + + private static @NonNull String getSql(UserAuthInfoQuery query) { + + switch (query.provider()) { + case "id" -> { + return "SELECT u from User u where u.id = :id"; + } + case "email" -> { + return "SELECT u from User u where u.email = :email"; + } + case "phone" -> { + return "SELECT u from User u where u.phone = :phone"; + } + case "username" -> { + return "SELECT u from User u where u.username = :username"; + } + default -> { + return "SELECT u from User u where u.id in (SELECT ua.userId from UserAuthority ua where ua.provider = :provider and ua.openId = :name)"; + } + } + } +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/model/OnetimePasswordDetail.java b/identity/src/main/java/io/theurl/identity/persistence/model/OnetimePasswordDetail.java new file mode 100644 index 0000000..1b7e9ec --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/model/OnetimePasswordDetail.java @@ -0,0 +1,15 @@ +package io.theurl.identity.persistence.model; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class OnetimePasswordDetail { + private Long id; + private String requestId; + private String code; + private String recipient; + private LocalDateTime expiration; + private LocalDateTime checked; +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/model/UserAuthInfo.java b/identity/src/main/java/io/theurl/identity/persistence/model/UserAuthInfo.java new file mode 100644 index 0000000..0845dfc --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/model/UserAuthInfo.java @@ -0,0 +1,19 @@ +package io.theurl.identity.persistence.model; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +public class UserAuthInfo { + private Long id; + private String username; + private String nickname; + private String passwordHash; + private String passwordSalt; + private String email; + private String phone; + private LocalDateTime lockedUntil; + private List roles; +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/query/OnetimePasswordDetailQuery.java b/identity/src/main/java/io/theurl/identity/persistence/query/OnetimePasswordDetailQuery.java new file mode 100644 index 0000000..ee9b079 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/query/OnetimePasswordDetailQuery.java @@ -0,0 +1,7 @@ +package io.theurl.identity.persistence.query; + +import com.neroyun.mediator.Query; +import io.theurl.identity.persistence.model.OnetimePasswordDetail; + +public record OnetimePasswordDetailQuery(String requestId) implements Query { +} diff --git a/identity/src/main/java/io/theurl/identity/persistence/query/UserAuthInfoQuery.java b/identity/src/main/java/io/theurl/identity/persistence/query/UserAuthInfoQuery.java new file mode 100644 index 0000000..95c4e60 --- /dev/null +++ b/identity/src/main/java/io/theurl/identity/persistence/query/UserAuthInfoQuery.java @@ -0,0 +1,8 @@ +package io.theurl.identity.persistence.query; + +import com.neroyun.mediator.Query; +import io.theurl.identity.persistence.model.UserAuthInfo; + +public record UserAuthInfoQuery(String provider, String name) implements Query { + +}