diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java index 6bba2db..6ec3294 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java @@ -3,12 +3,16 @@ package com.onixbyte.deltaforceguide.config; import com.onixbyte.deltaforceguide.interceptor.GitHubWebhookInterceptor; import com.onixbyte.deltaforceguide.interceptor.TrafficInterceptor; import com.onixbyte.deltaforceguide.properties.AppProperties; +import com.onixbyte.deltaforceguide.security.resolver.CurrentUserArgumentResolver; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import java.util.List; + @Configuration @EnableConfigurationProperties(AppProperties.class) public class AppConfig implements WebMvcConfigurer { @@ -17,13 +21,17 @@ public class AppConfig implements WebMvcConfigurer { private final GitHubWebhookInterceptor gitHubWebhookInterceptor; + private final CurrentUserArgumentResolver currentUserArgumentResolver; + @Autowired public AppConfig( TrafficInterceptor trafficInterceptor, - GitHubWebhookInterceptor gitHubWebhookInterceptor + GitHubWebhookInterceptor gitHubWebhookInterceptor, + CurrentUserArgumentResolver currentUserArgumentResolver ) { this.trafficInterceptor = trafficInterceptor; this.gitHubWebhookInterceptor = gitHubWebhookInterceptor; + this.currentUserArgumentResolver = currentUserArgumentResolver; } @Override @@ -32,4 +40,9 @@ public class AppConfig implements WebMvcConfigurer { registry.addInterceptor(gitHubWebhookInterceptor) .addPathPatterns("/webhooks/github"); } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(currentUserArgumentResolver); + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java index 7d75f6a..fc0b81d 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java @@ -6,14 +6,22 @@ import com.auth0.jwt.algorithms.Algorithm; import com.onixbyte.deltaforceguide.filter.TokenAuthenticationFilter; import com.onixbyte.deltaforceguide.properties.CookieProperties; import com.onixbyte.deltaforceguide.properties.TokenProperties; +import com.onixbyte.deltaforceguide.security.annotation.RequiresRole; +import com.onixbyte.deltaforceguide.security.authorisation.RoleBasedAuthorizationManager; import com.onixbyte.deltaforceguide.security.provider.UsernamePasswordAuthenticationProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.aop.Advisor; +import org.springframework.aop.support.annotation.AnnotationMatchingPointcut; +import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authorization.method.AuthorizationInterceptorsOrder; +import org.springframework.security.authorization.method.AuthorizationManagerBeforeMethodInterceptor; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -109,4 +117,21 @@ public class SecurityConfig { .withIssuer(tokenProperties.issuer()) .build(); } + + /** + * Registers the {@link RequiresRole} annotation as a method-security interceptor. + * Methods and classes annotated with {@code @RequiresRole} are checked by the + * {@link RoleBasedAuthorizationManager}. + * + * @param manager the role-based authorisation manager + * @return the advisor that intercepts {@code @RequiresRole}-annotated methods + */ + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + static Advisor requiresRoleAdvisor(RoleBasedAuthorizationManager manager) { + var pointcut = new AnnotationMatchingPointcut(null, RequiresRole.class, true); + var interceptor = new AuthorizationManagerBeforeMethodInterceptor(pointcut, manager); + interceptor.setOrder(AuthorizationInterceptorsOrder.PRE_AUTHORIZE.getOrder() + 10); + return interceptor; + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/AdminController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/AdminController.java new file mode 100644 index 0000000..fd65341 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/AdminController.java @@ -0,0 +1,60 @@ +package com.onixbyte.deltaforceguide.controller; + +import com.onixbyte.deltaforceguide.domain.dto.ManageRoleRequest; +import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth; +import com.onixbyte.deltaforceguide.security.annotation.RequiresRole; +import com.onixbyte.deltaforceguide.service.UserService; +import com.onixbyte.deltaforceguide.shared.Role; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * REST controller for administrative user and role management. + * + * @author zihluwang + */ +@Tag(name = "管理员", description = "管理员用户与角色管理") +@RestController +@RequestMapping("/admin") +@Validated +@RequiresAuth +public class AdminController { + + private final UserService userService; + + public AdminController(UserService userService) { + this.userService = userService; + } + + @Operation(description = "为用户分配角色(仅超级管理员可操作)") + @RequiresRole(Role.ROLE_SUPER_USER) + @PutMapping("/users/{id}/roles") + public ResponseEntity assignRole( + @PathVariable @Positive(message = "用户ID必须为正数") Long id, + @Valid @RequestBody ManageRoleRequest request + ) { + userService.assignRole(id, request.role()); + return ResponseEntity.noContent().build(); + } + + @Operation(description = "移除用户角色(仅超级管理员可操作)") + @RequiresRole(Role.ROLE_SUPER_USER) + @DeleteMapping("/users/{id}/roles/{role}") + public ResponseEntity removeRole( + @PathVariable @Positive(message = "用户ID必须为正数") Long id, + @PathVariable String role + ) { + userService.removeRole(id, role); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/UserController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/UserController.java new file mode 100644 index 0000000..9678eb6 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/UserController.java @@ -0,0 +1,79 @@ +package com.onixbyte.deltaforceguide.controller; + +import com.onixbyte.deltaforceguide.domain.dto.BuildSummaryResponse; +import com.onixbyte.deltaforceguide.domain.dto.ChangePasswordRequest; +import com.onixbyte.deltaforceguide.domain.dto.PageResponse; +import com.onixbyte.deltaforceguide.domain.dto.UpdateProfileRequest; +import com.onixbyte.deltaforceguide.domain.dto.UserProfileResponse; +import com.onixbyte.deltaforceguide.domain.entity.User; +import com.onixbyte.deltaforceguide.security.annotation.RequiresAuth; +import com.onixbyte.deltaforceguide.security.resolver.CurrentUser; +import com.onixbyte.deltaforceguide.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * REST controller for the current user's profile management endpoints. + * + * @author zihluwang + */ +@Tag(name = "用户个人中心", description = "当前用户的资料管理与密码修改") +@RestController +@RequestMapping("/user") +@Validated +@RequiresAuth +public class UserController { + + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } + + @Operation(description = "获取当前登录用户的个人资料") + @GetMapping("/me") + public UserProfileResponse profile(@CurrentUser User user) { + return userService.getProfile(user); + } + + @Operation(description = "修改当前用户的个人资料(昵称与邮箱)") + @PutMapping("/me") + public UserProfileResponse updateProfile( + @CurrentUser User user, + @Valid @RequestBody UpdateProfileRequest request + ) { + return userService.updateProfile(user, request); + } + + @Operation(description = "修改当前用户的登录密码") + @PutMapping("/me/password") + public ResponseEntity changePassword( + @CurrentUser User user, + @Valid @RequestBody ChangePasswordRequest request + ) { + userService.changePassword(user, request); + return ResponseEntity.noContent().build(); + } + + @Operation(description = "分页查询当前用户的改枪码列表") + @GetMapping("/me/builds") + public PageResponse builds( + @CurrentUser User user, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return userService.getBuilds(user, PageRequest.of(page, size)); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/BuildSummaryResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/BuildSummaryResponse.java new file mode 100644 index 0000000..e900bac --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/BuildSummaryResponse.java @@ -0,0 +1,20 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import java.util.List; + +/** + * Response record summarising a user's custom modification build. + * This is a skeleton record whose {@code from()} factory will be fully implemented + * when the CustomModification entity is introduced in a future issue. + * + * @author zihluwang + */ +public record BuildSummaryResponse( + Long id, + String name, + String code, + String firearmName, + List tags +) { + // from() factory to be implemented when CustomModification entity lands +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ChangePasswordRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ChangePasswordRequest.java new file mode 100644 index 0000000..1cd829c --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ChangePasswordRequest.java @@ -0,0 +1,23 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * Request record for changing the current user's password. + * + * @author zihluwang + */ +public record ChangePasswordRequest( + @NotBlank(message = "旧密码不能为空") + String oldPassword, + + @NotBlank(message = "新密码不能为空") + @Size(min = 8, max = 128, message = "密码长度必须在8-128个字符之间") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$", + message = "密码必须包含至少一个大写字母、一个小写字母和一个数字" + ) + String newPassword +) {} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ManageRoleRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ManageRoleRequest.java new file mode 100644 index 0000000..72ba68e --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ManageRoleRequest.java @@ -0,0 +1,13 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import jakarta.validation.constraints.NotBlank; + +/** + * Request record for managing a user's role assignment. + * + * @author zihluwang + */ +public record ManageRoleRequest( + @NotBlank(message = "角色不能为空") + String role +) {} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UpdateProfileRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UpdateProfileRequest.java new file mode 100644 index 0000000..0d1b016 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UpdateProfileRequest.java @@ -0,0 +1,18 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Size; + +/** + * Request record for updating the current user's profile information. + * + * @author zihluwang + */ +public record UpdateProfileRequest( + @Size(min = 1, max = 64, message = "昵称长度必须在1-64个字符之间") + String nickname, + + @Email(message = "请输入正确的电子邮箱地址") + @Size(max = 255, message = "电子邮箱长度不能超过255个字符") + String email +) {} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserProfileResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserProfileResponse.java new file mode 100644 index 0000000..5609119 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserProfileResponse.java @@ -0,0 +1,46 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import com.onixbyte.deltaforceguide.domain.entity.User; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * Response record for the current user's full profile information. + * + * @author zihluwang + */ +public record UserProfileResponse( + Long id, + String username, + String nickname, + String email, + String avatarUrl, + Boolean emailVerified, + Boolean enabled, + LocalDateTime createdAt, + List roles +) { + + /** + * Creates a {@code UserProfileResponse} from a {@link User} entity and its assigned roles. + * + * @param user the user entity + * @param roles the list of role names assigned to the user + * @return the constructed response + */ + public static UserProfileResponse from(User user, List roles) { + return new UserProfileResponse( + user.getId(), + user.getUsername(), + user.getNickname() == null || user.getNickname().isBlank() + ? user.getUsername() : user.getNickname(), + user.getEmail(), + user.getAvatarUrl(), + user.getEmailVerified(), + user.getEnabled(), + user.getCreatedAt(), + roles + ); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java index d696b2d..c061c09 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java @@ -7,8 +7,10 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.OneToMany; +import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -31,6 +33,24 @@ public class User { @Column(name = "email", nullable = false) private String email; + @Column(name = "nickname") + private String nickname; + + @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; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List credentials = new ArrayList<>(); @@ -58,6 +78,59 @@ public class User { this.email = email; } + public String getNickname() { + return nickname; + } + + public void setNickname(String nickname) { + this.nickname = nickname; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + + public Boolean getEmailVerified() { + return emailVerified; + } + + public void setEmailVerified(Boolean emailVerified) { + this.emailVerified = emailVerified; + } + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + @PreUpdate + private void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } + public List getCredentials() { return credentials; } @@ -85,6 +158,12 @@ public class User { private Long id; private String username; private String email; + private String nickname; + private String avatarUrl; + private Boolean emailVerified; + private Boolean enabled; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; private List credentials; public Builder id(Long id) { @@ -102,6 +181,36 @@ public class User { return this; } + public Builder nickname(String nickname) { + this.nickname = nickname; + return this; + } + + public Builder avatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + return this; + } + + public Builder emailVerified(Boolean emailVerified) { + this.emailVerified = emailVerified; + return this; + } + + public Builder enabled(Boolean enabled) { + this.enabled = enabled; + return this; + } + + public Builder createdAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public Builder updatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + return this; + } + public Builder credentials(List credentials) { this.credentials = credentials; return this; @@ -112,6 +221,13 @@ public class User { user.id = this.id; user.username = this.username; user.email = this.email; + 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; user.credentials = this.credentials == null ? new ArrayList<>() : this.credentials; return user; } diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserRole.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserRole.java new file mode 100644 index 0000000..8caf84e --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserRole.java @@ -0,0 +1,125 @@ +package com.onixbyte.deltaforceguide.domain.entity; + +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.AttributeOverrides; +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapsId; +import jakarta.persistence.Table; + +/** + * Entity representing a role assignment linking a user to a security role. + * + * @author zihluwang + */ +@Entity +@Table(name = "user_role") +public class UserRole { + + @EmbeddedId + @AttributeOverrides({ + @AttributeOverride(name = "userId", column = @Column(name = "user_id")), + @AttributeOverride(name = "role", column = @Column(name = "role")) + }) + private UserRoleId id = new UserRoleId(); + + @MapsId("userId") + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_role_user")) + private User user; + + public UserRoleId getId() { + return id; + } + + public void setId(UserRoleId id) { + this.id = id; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + if (this.id == null) { + this.id = new UserRoleId(); + } + this.id.setUserId(user == null ? null : user.getId()); + } + + public Long getUserId() { + return id == null ? null : id.getUserId(); + } + + public void setUserId(Long userId) { + if (this.id == null) { + this.id = new UserRoleId(); + } + this.id.setUserId(userId); + } + + public String getRole() { + return id == null ? null : id.getRole(); + } + + public void setRole(String role) { + if (this.id == null) { + this.id = new UserRoleId(); + } + this.id.setRole(role); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private UserRoleId id; + private User user; + private Long userId; + private String role; + + public Builder id(UserRoleId id) { + this.id = id; + return this; + } + + public Builder user(User user) { + this.user = user; + return this; + } + + public Builder userId(Long userId) { + this.userId = userId; + return this; + } + + public Builder role(String role) { + this.role = role; + return this; + } + + public UserRole build() { + UserRole userRole = new UserRole(); + userRole.id = this.id == null ? new UserRoleId() : this.id; + userRole.user = this.user; + if (this.user != null) { + userRole.id.setUserId(this.user.getId()); + } + if (this.userId != null) { + userRole.id.setUserId(this.userId); + } + if (this.role != null) { + userRole.id.setRole(this.role); + } + return userRole; + } + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserRoleId.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserRoleId.java new file mode 100644 index 0000000..96f5b0e --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserRoleId.java @@ -0,0 +1,79 @@ +package com.onixbyte.deltaforceguide.domain.entity; + +import jakarta.persistence.Embeddable; + +import java.io.Serializable; +import java.util.Objects; + +/** + * Composite key for the UserRole entity, combining user ID and role name. + * + * @author zihluwang + */ +@Embeddable +public class UserRoleId implements Serializable { + + private Long userId; + + private String role; + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + UserRoleId that = (UserRoleId) o; + return Objects.equals(userId, that.userId) && Objects.equals(role, that.role); + } + + @Override + public int hashCode() { + return Objects.hash(userId, role); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private Long userId; + private String role; + + public Builder userId(Long userId) { + this.userId = userId; + return this; + } + + public Builder role(String role) { + this.role = role; + return this; + } + + public UserRoleId build() { + UserRoleId id = new UserRoleId(); + id.userId = this.userId; + id.role = this.role; + return id; + } + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java b/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java index e2068a6..1a690e1 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java +++ b/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java @@ -4,6 +4,7 @@ import com.auth0.jwt.exceptions.JWTVerificationException; import com.onixbyte.deltaforceguide.client.TokenClient; import com.onixbyte.deltaforceguide.exeption.BizException; import com.onixbyte.deltaforceguide.manager.UserManager; +import com.onixbyte.deltaforceguide.manager.UserRoleManager; import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication; import com.onixbyte.deltaforceguide.service.CookieService; import jakarta.servlet.FilterChain; @@ -16,6 +17,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -27,6 +29,7 @@ import java.time.Duration; import java.time.Instant; import java.util.Objects; import java.util.Optional; +import java.util.stream.Collectors; /** * Servlet filter that extracts and validates JWT tokens from httpOnly cookies for each request. @@ -40,17 +43,20 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter { private static final Duration ACCESS_TOKEN_RENEW_THRESHOLD = Duration.ofMinutes(5); private final UserManager userManager; + private final UserRoleManager userRoleManager; private final TokenClient tokenClient; private final CookieService cookieService; private final HandlerExceptionResolver handlerExceptionResolver; public TokenAuthenticationFilter( UserManager userManager, + UserRoleManager userRoleManager, TokenClient tokenClient, CookieService cookieService, HandlerExceptionResolver handlerExceptionResolver ) { this.userManager = userManager; + this.userRoleManager = userRoleManager; this.tokenClient = tokenClient; this.cookieService = cookieService; this.handlerExceptionResolver = handlerExceptionResolver; @@ -87,7 +93,11 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter { } var user = userWrapper.get(); - var authentication = UsernamePasswordAuthentication.authenticated(user); + var roles = userRoleManager.findAllByUserId(user.getId()); + var authorities = roles.stream() + .map(r -> new SimpleGrantedAuthority(r.getRole())) + .collect(Collectors.toList()); + var authentication = UsernamePasswordAuthentication.authenticated(user, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); if (shouldRenew(decodedToken.getExpiresAt().toInstant())) { diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/UserRoleManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/UserRoleManager.java new file mode 100644 index 0000000..4a2058c --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/UserRoleManager.java @@ -0,0 +1,72 @@ +package com.onixbyte.deltaforceguide.manager; + +import com.onixbyte.deltaforceguide.domain.entity.UserRole; +import com.onixbyte.deltaforceguide.repository.UserRoleRepository; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Manager for user role persistence and authorisation data access. + * + * @author zihluwang + */ +@Component +public class UserRoleManager { + + private final UserRoleRepository userRoleRepository; + + public UserRoleManager(UserRoleRepository userRoleRepository) { + this.userRoleRepository = userRoleRepository; + } + + /** + * Finds all role assignments for a specific user. + * + * @param userId the user ID + * @return list of matching role assignments + */ + public List findAllByUserId(Long userId) { + return userRoleRepository.findAllByUserId(userId); + } + + /** + * Checks whether a user has a specific role. + * + * @param userId the user ID + * @param role the role name + * @return true if the role is assigned, false otherwise + */ + public boolean existsByUserIdAndRole(Long userId, String role) { + return userRoleRepository.existsByUserIdAndRole(userId, role); + } + + /** + * Persists a new role assignment. + * + * @param userRole the role assignment to save + * @return the saved role assignment + */ + public UserRole save(UserRole userRole) { + return userRoleRepository.save(userRole); + } + + /** + * Deletes a role assignment for a specific user and role. + * + * @param userId the user ID + * @param role the role name + */ + public void deleteByUserIdAndRole(Long userId, String role) { + userRoleRepository.deleteByUserIdAndRole(userId, role); + } + + /** + * Deletes all role assignments belonging to a user. + * + * @param userId the user ID + */ + public void deleteAllByUserId(Long userId) { + userRoleRepository.deleteAllByUserId(userId); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/UserRoleRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/UserRoleRepository.java new file mode 100644 index 0000000..f2ce1e8 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/UserRoleRepository.java @@ -0,0 +1,76 @@ +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.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * Spring Data JPA repository for {@link UserRole} entity operations. + * + * @author zihluwang + */ +@Repository +public interface UserRoleRepository extends JpaRepository { + + /** + * Find all roles assigned to a given user. + * + * @param userId the user ID + * @return list of matching role assignments + */ + @EntityGraph(attributePaths = {"user"}) + @Query(""" + select ur + from UserRole ur + where ur.user.id = :userId + """) + List findAllByUserId(@Param("userId") Long userId); + + /** + * Check whether a user has a specific role. + * + * @param userId the user ID + * @param role the role name + * @return true if the role is assigned, false otherwise + */ + @Query(""" + select count(ur) > 0 + from UserRole ur + where ur.user.id = :userId + and ur.id.role = :role + """) + boolean existsByUserIdAndRole(@Param("userId") Long userId, @Param("role") String role); + + /** + * Delete a specific role assignment for a user. + * + * @param userId the user ID + * @param role the role name + */ + @Modifying + @Query(""" + delete from UserRole ur + where ur.user.id = :userId + and ur.id.role = :role + """) + void deleteByUserIdAndRole(@Param("userId") Long userId, @Param("role") String role); + + /** + * Delete all role assignments for a given user. + * + * @param userId the user ID + */ + @Modifying + @Query(""" + delete from UserRole ur + where ur.user.id = :userId + """) + void deleteAllByUserId(@Param("userId") Long userId); +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresRole.java b/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresRole.java new file mode 100644 index 0000000..ae3e71c --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresRole.java @@ -0,0 +1,24 @@ +package com.onixbyte.deltaforceguide.security.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation for protecting controller methods or classes with role-based access control. + * When applied, the current authenticated user must possess at least one of the specified roles. + * + * @author zihluwang + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequiresRole { + + /** + * The required role names. The user must possess at least one of these. + * + * @return array of role name strings + */ + String[] value(); +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java b/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java index 9bd2ae3..c6eb826 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java +++ b/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java @@ -5,6 +5,7 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.CredentialsContainer; import org.springframework.security.core.GrantedAuthority; +import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -18,25 +19,40 @@ public class UsernamePasswordAuthentication implements Authentication, Credentia private String password; private boolean authenticated; private User user; + private Collection authorities; - private UsernamePasswordAuthentication(String username, String password, boolean authenticated, User user) { + private UsernamePasswordAuthentication( + String username, + String password, + boolean authenticated, + User user, + Collection authorities + ) { this.username = username; this.password = password; this.authenticated = authenticated; this.user = user; + this.authorities = authorities == null ? List.of() : new ArrayList<>(authorities); } public static UsernamePasswordAuthentication unauthenticated(String username, String password) { - return new UsernamePasswordAuthentication(username, password, false, null); + return new UsernamePasswordAuthentication(username, password, false, null, List.of()); } public static UsernamePasswordAuthentication authenticated(User user) { - return new UsernamePasswordAuthentication(user.getUsername(), null, true, user); + return new UsernamePasswordAuthentication(user.getUsername(), null, true, user, List.of()); + } + + public static UsernamePasswordAuthentication authenticated( + User user, + Collection authorities + ) { + return new UsernamePasswordAuthentication(user.getUsername(), null, true, user, authorities); } @Override public Collection getAuthorities() { - return List.of(); + return authorities; } @Override diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/authorisation/RoleBasedAuthorizationManager.java b/src/main/java/com/onixbyte/deltaforceguide/security/authorisation/RoleBasedAuthorizationManager.java new file mode 100644 index 0000000..90f95ee --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/security/authorisation/RoleBasedAuthorizationManager.java @@ -0,0 +1,57 @@ +package com.onixbyte.deltaforceguide.security.authorisation; + +import com.onixbyte.deltaforceguide.security.annotation.RequiresRole; +import org.aopalliance.intercept.MethodInvocation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +import java.util.Set; +import java.util.function.Supplier; + +/** + * Spring Security {@link AuthorizationManager} that enforces {@link RequiresRole} annotations. + * Checks that the authenticated user possesses at least one of the required roles. + * + * @author zihluwang + */ +@Component +public class RoleBasedAuthorizationManager implements AuthorizationManager { + + private static final Logger log = LoggerFactory.getLogger(RoleBasedAuthorizationManager.class); + + @Override + public AuthorizationDecision check( + Supplier authenticationSupplier, + MethodInvocation invocation + ) { + var auth = authenticationSupplier.get(); + if (auth == null || !auth.isAuthenticated()) { + log.debug("Access denied: no authenticated user"); + return new AuthorizationDecision(false); + } + + var annotation = invocation.getMethod().getAnnotation(RequiresRole.class); + if (annotation == null) { + annotation = invocation.getMethod().getDeclaringClass() + .getAnnotation(RequiresRole.class); + } + if (annotation == null) { + return new AuthorizationDecision(true); + } + + var requiredRoles = Set.of(annotation.value()); + var hasRole = auth.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .anyMatch(requiredRoles::contains); + + if (!hasRole) { + log.debug("Access denied: user lacks required roles {}", requiredRoles); + } + return new AuthorizationDecision(hasRole); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/resolver/CurrentUser.java b/src/main/java/com/onixbyte/deltaforceguide/security/resolver/CurrentUser.java new file mode 100644 index 0000000..5c66d3e --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/security/resolver/CurrentUser.java @@ -0,0 +1,16 @@ +package com.onixbyte.deltaforceguide.security.resolver; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation for injecting the currently authenticated {@code User} into controller method + * parameters. The resolver extracts the user from the {@code SecurityContext}. + * + * @author zihluwang + */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface CurrentUser {} diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/resolver/CurrentUserArgumentResolver.java b/src/main/java/com/onixbyte/deltaforceguide/security/resolver/CurrentUserArgumentResolver.java new file mode 100644 index 0000000..3ab241e --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/security/resolver/CurrentUserArgumentResolver.java @@ -0,0 +1,44 @@ +package com.onixbyte.deltaforceguide.security.resolver; + +import com.onixbyte.deltaforceguide.domain.entity.User; +import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import org.springframework.web.server.ResponseStatusException; + +/** + * Resolves {@link CurrentUser}-annotated controller method parameters by extracting the + * {@link User} entity from the current {@link UsernamePasswordAuthentication} in the + * security context. + * + * @author zihluwang + */ +@Component +public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUser.class) + && parameter.getParameterType().equals(User.class); + } + + @Override + public User resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + var auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth instanceof UsernamePasswordAuthentication upa && upa.isAuthenticated()) { + return upa.getDetails(); + } + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "请先登录"); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java b/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java index 700e26d..0d4a433 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java @@ -1,14 +1,29 @@ package com.onixbyte.deltaforceguide.service; +import com.onixbyte.deltaforceguide.domain.dto.BuildSummaryResponse; +import com.onixbyte.deltaforceguide.domain.dto.ChangePasswordRequest; +import com.onixbyte.deltaforceguide.domain.dto.PageResponse; +import com.onixbyte.deltaforceguide.domain.dto.UpdateProfileRequest; +import com.onixbyte.deltaforceguide.domain.dto.UserProfileResponse; import com.onixbyte.deltaforceguide.domain.entity.User; import com.onixbyte.deltaforceguide.domain.entity.UserCredential; +import com.onixbyte.deltaforceguide.domain.entity.UserRole; +import com.onixbyte.deltaforceguide.exeption.BizException; import com.onixbyte.deltaforceguide.manager.UserCredentialManager; import com.onixbyte.deltaforceguide.manager.UserManager; +import com.onixbyte.deltaforceguide.manager.UserRoleManager; +import com.onixbyte.deltaforceguide.shared.CredentialProvider; +import com.onixbyte.deltaforceguide.shared.Role; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; import org.springframework.web.server.ResponseStatusException; import java.util.List; +import java.util.Objects; /** * Service for user account management and profile operations. @@ -20,10 +35,19 @@ public class UserService { private final UserManager userManager; private final UserCredentialManager userCredentialManager; + private final UserRoleManager userRoleManager; + private final PasswordEncoder passwordEncoder; - public UserService(UserManager userManager, UserCredentialManager userCredentialManager) { + public UserService( + UserManager userManager, + UserCredentialManager userCredentialManager, + UserRoleManager userRoleManager, + PasswordEncoder passwordEncoder + ) { this.userManager = userManager; this.userCredentialManager = userCredentialManager; + this.userRoleManager = userRoleManager; + this.passwordEncoder = passwordEncoder; } /** @@ -150,6 +174,106 @@ public class UserService { return userManager.findById(userId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + userId)); } + + /** + * Builds the full profile response for the given user, including their assigned roles. + * + * @param user the authenticated user + * @return the user's profile response + */ + public UserProfileResponse getProfile(User user) { + var roles = userRoleManager.findAllByUserId(user.getId()).stream() + .map(UserRole::getRole) + .toList(); + return UserProfileResponse.from(user, roles); + } + + /** + * Updates the current user's profile information (nickname and email). + * If the email address changes, the email verification status is reset. + * + * @param user the authenticated user + * @param request the update request containing new nickname and email + * @return the updated user profile response + */ + public UserProfileResponse updateProfile(User user, UpdateProfileRequest request) { + 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); + } + + /** + * Changes the current user's password after validating the old password. + * + * @param user the authenticated user + * @param request the change password request containing old and new passwords + * @throws BizException if the old password is incorrect + */ + public void changePassword(User user, ChangePasswordRequest request) { + var credential = userCredentialManager.findByUserIdAndProvider(user.getId(), CredentialProvider.LOCAL); + if (credential.isEmpty() || !passwordEncoder.matches(request.oldPassword(), credential.get().getCredential())) { + throw new BizException(HttpStatus.BAD_REQUEST, "旧密码不正确。"); + } + var encoded = passwordEncoder.encode(request.newPassword()); + upsertCredential(user.getId(), CredentialProvider.LOCAL, encoded); + } + + /** + * Retrieves a paginated list of the current user's custom modification builds. + * This is a skeleton implementation that returns an empty page. + * It will be activated when the CustomModification entity is introduced. + * + * @param user the authenticated user + * @param pageable pagination parameters + * @return an empty paginated response + */ + public PageResponse getBuilds(User user, Pageable pageable) { + var emptyPage = Page.empty(pageable); + return PageResponse.from(emptyPage); + } + + /** + * Assigns a role to a user. Only {@code ROLE_ADMIN} may be assigned via this API. + * + * @param userId the target user ID + * @param role the role to assign + * @throws BizException if attempting to assign a protected role + */ + public void assignRole(Long userId, String role) { + if (!Role.ROLE_ADMIN.equals(role)) { + throw new BizException(HttpStatus.BAD_REQUEST, "只能通过API分配ROLE_ADMIN角色。"); + } + var user = ensureUserExists(userId); + if (userRoleManager.existsByUserIdAndRole(userId, role)) { + return; // already assigned — idempotent + } + var userRole = UserRole.builder() + .user(user) + .role(role) + .build(); + userRoleManager.save(userRole); + } + + /** + * Removes a role from a user. Only {@code ROLE_ADMIN} may be removed via this API. + * + * @param userId the target user ID + * @param role the role to remove + * @throws BizException if attempting to remove a protected role + */ + public void removeRole(Long userId, String role) { + if (!Role.ROLE_ADMIN.equals(role)) { + throw new BizException(HttpStatus.BAD_REQUEST, "只能通过API移除ROLE_ADMIN角色。"); + } + ensureUserExists(userId); + userRoleManager.deleteByUserIdAndRole(userId, role); + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/shared/Role.java b/src/main/java/com/onixbyte/deltaforceguide/shared/Role.java new file mode 100644 index 0000000..719321e --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/shared/Role.java @@ -0,0 +1,15 @@ +package com.onixbyte.deltaforceguide.shared; + +/** + * Constants for application role names used in authorisation decisions. + * + * @author zihluwang + */ +public class Role { + + public static final String ROLE_USER = "ROLE_USER"; + public static final String ROLE_ADMIN = "ROLE_ADMIN"; + public static final String ROLE_SUPER_USER = "ROLE_SUPER_USER"; + + private Role() {} +} diff --git a/src/main/resources/db/migration/V6__user_profile.sql b/src/main/resources/db/migration/V6__user_profile.sql new file mode 100644 index 0000000..6bb1ff8 --- /dev/null +++ b/src/main/resources/db/migration/V6__user_profile.sql @@ -0,0 +1,7 @@ +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(); diff --git a/src/main/resources/db/migration/V7__user_role.sql b/src/main/resources/db/migration/V7__user_role.sql new file mode 100644 index 0000000..57a0049 --- /dev/null +++ b/src/main/resources/db/migration/V7__user_role.sql @@ -0,0 +1,6 @@ +CREATE TABLE user_role +( + user_id BIGINT NOT NULL REFERENCES app_user (id), + role VARCHAR(32) NOT NULL, + CONSTRAINT user_role_pkey PRIMARY KEY (user_id, role) +);