feat(auth): add user profile centre and admin role system

- add GET/PUT /user/me endpoints for profile viewing and editing
- add PUT /user/me/password endpoint for password changes
- add GET /user/me/builds skeleton endpoint for future CustomModification
- add user_role table with ROLE_USER, ROLE_ADMIN, and ROLE_SUPER_USER roles
- add @RequiresRole annotation with custom AuthorizationManager enforcement
- extend User entity with nickname, avatarUrl, emailVerified, enabled,
  createdAt, and updatedAt columns
- load user roles into SecurityContext authorities via TokenAuthenticationFilter
- add @CurrentUser annotation and argument resolver for controller injection
- add PUT/DELETE /admin/users/{id}/roles for SUPER_USER role management
- add V6 and V7 Flyway migrations for schema changes

Closes #5
Closes #8
This commit is contained in:
zihluwang committed 2026-07-21 13:44:02 +08:00
1 parent 0aeaac17a4
commit db5056e8d5
24 files changed
+1091 -7

No files matched your search

@@ -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<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(currentUserArgumentResolver);
}
}
@@ -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;
}
}
@@ -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<Void> 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<Void> removeRole(
@PathVariable @Positive(message = "用户ID必须为正数") Long id,
@PathVariable String role
) {
userService.removeRole(id, role);
return ResponseEntity.noContent().build();
}
}
@@ -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<Void> changePassword(
@CurrentUser User user,
@Valid @RequestBody ChangePasswordRequest request
) {
userService.changePassword(user, request);
return ResponseEntity.noContent().build();
}
@Operation(description = "分页查询当前用户的改枪码列表")
@GetMapping("/me/builds")
public PageResponse<BuildSummaryResponse> 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));
}
}
@@ -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<String> tags
) {
// from() factory to be implemented when CustomModification entity lands
}
@@ -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
) {}
@@ -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
) {}
@@ -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
) {}
@@ -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<String> 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<String> 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
);
}
}
@@ -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<UserCredential> 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<UserCredential> 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<UserCredential> 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<UserCredential> 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;
}
@@ -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;
}
}
}
@@ -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;
}
}
}
@@ -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())) {
@@ -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<UserRole> 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);
}
}
@@ -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<UserRole, UserRoleId> {
/**
* 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<UserRole> 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);
}
@@ -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();
}
@@ -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<? extends GrantedAuthority> authorities;
private UsernamePasswordAuthentication(String username, String password, boolean authenticated, User user) {
private UsernamePasswordAuthentication(
String username,
String password,
boolean authenticated,
User user,
Collection<? extends GrantedAuthority> 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<? extends GrantedAuthority> authorities
) {
return new UsernamePasswordAuthentication(user.getUsername(), null, true, user, authorities);
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of();
return authorities;
}
@Override
@@ -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<MethodInvocation> {
private static final Logger log = LoggerFactory.getLogger(RoleBasedAuthorizationManager.class);
@Override
public AuthorizationDecision check(
Supplier<Authentication> 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);
}
}
@@ -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 {}
@@ -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, "请先登录");
}
}
@@ -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<BuildSummaryResponse> getBuilds(User user, Pageable pageable) {
var emptyPage = Page.<BuildSummaryResponse>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);
}
}
@@ -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() {}
}
@@ -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();
@@ -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)
);