diff --git a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java index 4534b46..52ca638 100644 --- a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java +++ b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java @@ -14,6 +14,7 @@ import inha.gdgoc.domain.auth.service.MailService; import inha.gdgoc.domain.auth.service.RefreshTokenService; import inha.gdgoc.domain.user.entity.User; +import inha.gdgoc.domain.user.enums.TeamType; import inha.gdgoc.domain.user.enums.UserRole; import inha.gdgoc.domain.user.repository.UserRepository; import inha.gdgoc.global.config.jwt.TokenProvider; @@ -165,15 +166,30 @@ public ResponseEntity> resetPassword(@RequestBody Passwo * 예) /api/v1/auth/LEAD, /api/v1/auth/ORGANIZER, /api/v1/auth/ADMIN */ @GetMapping("/{role}") - public ResponseEntity> checkRole(@AuthenticationPrincipal TokenProvider.CustomUserDetails me, @PathVariable UserRole role) { + public ResponseEntity> checkRoleOrTeam(@AuthenticationPrincipal TokenProvider.CustomUserDetails me, @PathVariable UserRole role, @RequestParam(value = "team", required = false) TeamType requiredTeam) { + // 1) 인증 체크 if (me == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(ApiResponse.error(GlobalErrorCode.UNAUTHORIZED_USER.getStatus() .value(), GlobalErrorCode.UNAUTHORIZED_USER.getMessage(), null)); } - if (UserRole.hasAtLeast(me.getRole(), role)) { - return ResponseEntity.ok(ApiResponse.ok("ROLE_CHECK_PASSED", null)); + // 2) role check + final boolean roleOk = UserRole.hasAtLeast(me.getRole(), role); + + // 3) team check if team parameter exists + boolean teamOk = false; + if (requiredTeam != null) { + if (UserRole.hasAtLeast(me.getRole(), UserRole.ORGANIZER)) { + teamOk = true; + } else { + teamOk = (me.getTeam() != null && me.getTeam() == requiredTeam); + } + } + + // 4) OR 조건으로 최종 판정 + if (roleOk || teamOk) { + return ResponseEntity.ok(ApiResponse.ok("ROLE_OR_TEAM_CHECK_PASSED", null)); } return ResponseEntity.status(HttpStatus.FORBIDDEN) diff --git a/src/main/java/inha/gdgoc/domain/core/recruit/controller/CoreRecruitController.java b/src/main/java/inha/gdgoc/domain/core/recruit/controller/CoreRecruitController.java index d68dc36..7efdd33 100644 --- a/src/main/java/inha/gdgoc/domain/core/recruit/controller/CoreRecruitController.java +++ b/src/main/java/inha/gdgoc/domain/core/recruit/controller/CoreRecruitController.java @@ -52,7 +52,7 @@ public ResponseEntity> create( description = "전체 목록 또는 이름 검색 결과를 반환합니다.", security = { @SecurityRequirement(name = "BearerAuth") } ) - @PreAuthorize("hasRole('ADMIN')") + @PreAuthorize("hasAnyRole('LEAD', 'ORGANIZER', 'ADMIN')") @GetMapping("/applicants") public ResponseEntity, PageMeta>> getApplicants( @Parameter(description = "검색어(이름 부분 일치). 없으면 전체 조회", example = "홍길동") @@ -89,7 +89,7 @@ public ResponseEntity> getApplicant( @PathVariable Long id diff --git a/src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java b/src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java index b2dc02f..9cd59b2 100644 --- a/src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java +++ b/src/main/java/inha/gdgoc/domain/recruit/controller/RecruitMemberController.java @@ -85,7 +85,7 @@ public ResponseEntity> duplicatedPho } @Operation(summary = "특정 멤버 가입 신청서 조회", security = {@SecurityRequirement(name = "BearerAuth")}) - @PreAuthorize("hasRole('ADMIN')") + @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN') or T(inha.gdgoc.domain.user.enums.TeamType).HR == principal.team") @GetMapping("/recruit/members/{memberId}") public ResponseEntity> getSpecifiedMember( @PathVariable Long memberId @@ -100,7 +100,7 @@ public ResponseEntity> getSpecifiedMe description = "설정하려는 상태(NOT 현재 상태)를 body에 보내주세요. true=입금 완료, false=입금 미완료", security = { @SecurityRequirement(name = "BearerAuth") } ) - @PreAuthorize("hasRole('ADMIN')") + @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN') or T(inha.gdgoc.domain.user.enums.TeamType).HR == principal.team") @PatchMapping("/recruit/members/{memberId}/payment") public ResponseEntity> updatePayment( @PathVariable Long memberId, @@ -122,7 +122,7 @@ public ResponseEntity> updatePayment( description = "전체 목록 또는 이름 검색 결과를 반환합니다. 검색어(question)를 주면 이름 포함 검색, 없으면 전체 조회. sort랑 dir은 example 값 그대로 코딩하는 것 추천...", security = { @SecurityRequirement(name = "BearerAuth") } ) - @PreAuthorize("hasRole('ADMIN')") + @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN') or T(inha.gdgoc.domain.user.enums.TeamType).HR == principal.team") @GetMapping("/recruit/members") public ResponseEntity, PageMeta>> getMembers( @Parameter(description = "검색어(이름 부분 일치). 없으면 전체 조회", example = "소연") diff --git a/src/main/java/inha/gdgoc/domain/user/controller/UserAdminController.java b/src/main/java/inha/gdgoc/domain/user/controller/UserAdminController.java new file mode 100644 index 0000000..443f435 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/user/controller/UserAdminController.java @@ -0,0 +1,56 @@ +package inha.gdgoc.domain.user.controller; + +import inha.gdgoc.domain.user.dto.request.UpdateRoleRequest; +import inha.gdgoc.domain.user.dto.request.UpdateUserRoleTeamRequest; +import inha.gdgoc.domain.user.dto.response.UserSummaryResponse; +import inha.gdgoc.domain.user.service.UserAdminService; +import inha.gdgoc.global.config.jwt.TokenProvider.CustomUserDetails; +import inha.gdgoc.global.dto.response.ApiResponse; +import inha.gdgoc.global.dto.response.PageMeta; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/admin/users") +public class UserAdminController { + + private final UserAdminService userAdminService; + + @Operation(summary = "사용자 요약 목록 조회", security = {@SecurityRequirement(name = "BearerAuth")}) + @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN') or T(inha.gdgoc.domain.user.enums.TeamType).HR == principal.team") + @GetMapping + public ResponseEntity, PageMeta>> list(@RequestParam(required = false) String q, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "name") String sort, @RequestParam(defaultValue = "ASC") String dir) { + Sort.Direction direction = "ASC".equalsIgnoreCase(dir) ? Sort.Direction.ASC : Sort.Direction.DESC; + Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sort)); + + Page result = userAdminService.listUsers(q, pageable); + return ResponseEntity.ok(ApiResponse.ok("USER_SUMMARY_LIST_RETRIEVED", result, PageMeta.of(result))); + } + + @Operation(summary = "사용자 역할/팀 수정", security = {@SecurityRequirement(name = "BearerAuth")}) + @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN')") + @PatchMapping("/{userId}/role-team") + public ResponseEntity> updateRoleTeam(@AuthenticationPrincipal CustomUserDetails me, @PathVariable Long userId, @RequestBody UpdateUserRoleTeamRequest req) { + userAdminService.updateRoleAndTeam(me, userId, req); + return ResponseEntity.ok(ApiResponse.ok("USER_ROLE_TEAM_UPDATED")); + } + + @Operation(summary = "사용자 역할 수정", security = {@SecurityRequirement(name = "BearerAuth")}) + @PatchMapping("/{userId}/role") + @PreAuthorize("hasAnyRole('LEAD','ORGANIZER','ADMIN') or T(inha.gdgoc.domain.user.enums.TeamType).HR == principal.team") + public ResponseEntity> updateUserRole(@AuthenticationPrincipal CustomUserDetails me, @PathVariable Long userId, @RequestBody @Valid UpdateRoleRequest req) { + userAdminService.updateUserRoleWithRules(me, userId, req.role()); + return ResponseEntity.ok(ApiResponse.ok("USER_ROLE_UPDATED")); + } +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/user/dto/request/UpdateRoleRequest.java b/src/main/java/inha/gdgoc/domain/user/dto/request/UpdateRoleRequest.java new file mode 100644 index 0000000..201a45c --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/user/dto/request/UpdateRoleRequest.java @@ -0,0 +1,8 @@ +package inha.gdgoc.domain.user.dto.request; + +import inha.gdgoc.domain.user.enums.UserRole; +import jakarta.validation.constraints.NotNull; + +public record UpdateRoleRequest( + @NotNull UserRole role +) {} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/user/dto/request/UpdateUserRoleTeamRequest.java b/src/main/java/inha/gdgoc/domain/user/dto/request/UpdateUserRoleTeamRequest.java new file mode 100644 index 0000000..3b942e6 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/user/dto/request/UpdateUserRoleTeamRequest.java @@ -0,0 +1,9 @@ +package inha.gdgoc.domain.user.dto.request; + +import inha.gdgoc.domain.user.enums.TeamType; +import inha.gdgoc.domain.user.enums.UserRole; + +public record UpdateUserRoleTeamRequest( + UserRole role, // null 이면 변경 안 함 + TeamType team // null 이면 변경 안 함 +) {} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/user/dto/response/UserSummaryResponse.java b/src/main/java/inha/gdgoc/domain/user/dto/response/UserSummaryResponse.java new file mode 100644 index 0000000..0d9ff9f --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/user/dto/response/UserSummaryResponse.java @@ -0,0 +1,14 @@ +package inha.gdgoc.domain.user.dto.response; + +import inha.gdgoc.domain.user.enums.TeamType; +import inha.gdgoc.domain.user.enums.UserRole; + +public record UserSummaryResponse( + Long id, + String name, + String major, + String studentId, + String email, + UserRole userRole, + TeamType team +) {} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/user/entity/User.java b/src/main/java/inha/gdgoc/domain/user/entity/User.java index f05c240..dcff2f1 100644 --- a/src/main/java/inha/gdgoc/domain/user/entity/User.java +++ b/src/main/java/inha/gdgoc/domain/user/entity/User.java @@ -131,4 +131,6 @@ public void updatePassword(String password) throws NoSuchAlgorithmException, Inv public boolean isGuest() { return this.userRole == UserRole.GUEST; } + public void changeRole(UserRole role) { this.userRole = role; } + public void changeTeam(TeamType team) { this.team = team; } } \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java b/src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java index 664b3fd..3836883 100644 --- a/src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java +++ b/src/main/java/inha/gdgoc/domain/user/repository/UserRepository.java @@ -1,9 +1,15 @@ package inha.gdgoc.domain.user.repository; +import inha.gdgoc.domain.user.dto.response.UserSummaryResponse; import inha.gdgoc.domain.user.entity.User; import inha.gdgoc.domain.user.enums.TeamType; import inha.gdgoc.domain.user.enums.UserRole; +import org.jetbrains.annotations.NotNull; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Collection; @@ -29,4 +35,15 @@ public interface UserRepository extends JpaRepository, UserRepositor // 필요 시: 특정 팀 전체 멤버(역할 무관) List findByTeam(TeamType team); + + @Query(""" + select new inha.gdgoc.domain.user.dto.response.UserSummaryResponse( + u.id, u.name, u.major, u.studentId, u.email, u.userRole, u.team + ) + from User u + where (:q is null or :q = '' or u.name like concat('%', :q, '%')) + """) + Page findSummaries(@Param("q") String q, Pageable pageable); + + @NotNull Optional findById(@NotNull Long id); } \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/domain/user/service/UserAdminService.java b/src/main/java/inha/gdgoc/domain/user/service/UserAdminService.java new file mode 100644 index 0000000..4f46c26 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/user/service/UserAdminService.java @@ -0,0 +1,190 @@ +package inha.gdgoc.domain.user.service; + +import inha.gdgoc.domain.user.dto.request.UpdateUserRoleTeamRequest; +import inha.gdgoc.domain.user.dto.response.UserSummaryResponse; +import inha.gdgoc.domain.user.entity.User; +import inha.gdgoc.domain.user.enums.TeamType; +import inha.gdgoc.domain.user.enums.UserRole; +import inha.gdgoc.domain.user.repository.UserRepository; +import inha.gdgoc.global.config.jwt.TokenProvider.CustomUserDetails; +import inha.gdgoc.global.exception.BusinessException; +import inha.gdgoc.global.exception.GlobalErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class UserAdminService { + + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public Page listUsers(String q, Pageable pageable) { + return userRepository.findSummaries(q, pageable); + } + + /** + * 역할/팀 동시 수정 API (PATCH /admin/users/{userId}/role-team) + * - 공통 규칙: + * 에디터의 role은 타겟의 현재/신규 role보다 "엄격히 높아야" 함 + * - ADMIN: 자기 자신 강등 금지 + * - ORGANIZER: ADMIN 대상 수정 금지 + * - LEAD: + * - MEMBER/CORE만 수정 가능, 변경도 MEMBER/CORE로만 + * - HR-LEAD: 자기 자신 제외 타인의 팀 변경 가능 + * - 그 외 LEAD: 같은 팀 구성원만 수정 가능, 팀 변경 불가 + * - 팀 보유 가능 역할: CORE, LEAD (그 외는 팀 자동 null) + */ + @Transactional + public void updateRoleAndTeam(CustomUserDetails editor, Long targetUserId, UpdateUserRoleTeamRequest req) { + User editorUser = getEditor(editor); + User target = userRepository.findById(targetUserId) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND)); + + UserRole editorRole = editorUser.getUserRole(); + UserRole targetCurrentRole = target.getUserRole(); + + UserRole newRole = (req.role() != null ? req.role() : targetCurrentRole); + TeamType requestedTeam = (req.team() != null ? req.team() : target.getTeam()); + + // ✅ 팀 보유 가능한 역할만 팀 유지/지정 (CORE, LEAD만 가능) + TeamType newTeam = isTeamAssignableRole(newRole) ? requestedTeam : null; + + // 공통: 에디터는 타겟의 현재/신규 role보다 높아야 함 (동급 불가) + if (!(editorRole.rank() > targetCurrentRole.rank())) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "동급/상위 사용자의 정보는 변경할 수 없습니다."); + } + if (!(editorRole.rank() > newRole.rank())) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "자신보다 크거나 같은 권한으로 변경할 수 없습니다."); + } + + switch (editorRole) { + case ADMIN -> { + // 자기 자신 강등 금지 + if (editorUser.getId().equals(target.getId()) && newRole.rank() < UserRole.ADMIN.rank()) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "자기 자신을 강등할 수 없습니다."); + } + // ADMIN은 팀 변경 제한 없음 (위 정규화로 팀 자동 정리) + } + case ORGANIZER -> { + // ADMIN 대상은 수정 금지 + if (targetCurrentRole == UserRole.ADMIN) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "ADMIN 사용자는 수정할 수 없습니다."); + } + } + case LEAD -> { + if (editor.getTeam() == null) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD 토큰에 팀 정보가 없습니다."); + } + // LEAD는 MEMBER/CORE만 수정 가능, 변경도 MEMBER/CORE로만 + if (!(targetCurrentRole == UserRole.MEMBER || targetCurrentRole == UserRole.CORE)) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD는 MEMBER/CORE만 수정할 수 있습니다."); + } + if (!(newRole == UserRole.MEMBER || newRole == UserRole.CORE)) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD는 MEMBER/CORE로만 변경할 수 있습니다."); + } + + if (editor.getTeam() == TeamType.HR) { + // HR-LEAD: 본인 제외 타인 팀 변경 가능 + if (editorUser.getId().equals(target.getId())) { + // 본인은 팀 변경 불가 + if (req.team() != null && !Objects.equals(req.team(), target.getTeam())) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "HR-LEAD도 자기 자신의 팀은 변경할 수 없습니다."); + } + } + } else { + // 일반 LEAD: 같은 팀 구성원만, 팀 변경 불가 + if (target.getTeam() != editor.getTeam()) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "다른 팀 사용자는 수정할 수 없습니다."); + } + if (req.team() != null && !Objects.equals(req.team(), editor.getTeam())) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "LEAD는 팀을 변경할 수 없습니다."); + } + } + } + default -> throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER); + } + + // ✅ 최종 반영 (역할이 팀 불가면 팀은 자동 null 처리) + targetChange(target, newRole, newTeam); + } + + /** + * 역할만 수정 API (PATCH /admin/users/{userId}/role) + * - HR-CORE 특례: GUEST -> MEMBER 가능 (그 외 불가) + * - 일반 규칙: 에디터의 role은 대상의 현재/신규 role보다 엄격히 높아야 함 + * - 역할이 팀 불가가 되면 팀은 자동 null 처리 + */ + @Transactional + public void updateUserRoleWithRules(CustomUserDetails me, Long targetUserId, UserRole newRole) { + var meRole = me.getRole(); + var meTeam = me.getTeam(); + + var target = userRepository.findById(targetUserId) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.RESOURCE_NOT_FOUND)); + + if (Objects.equals(me.getUserId(), targetUserId)) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "자기 자신의 역할은 변경할 수 없습니다."); + } + + UserRole current = target.getUserRole(); + + // ✅ HR-CORE 특례: GUEST → MEMBER 만 허용 + boolean isHrCore = (meRole == UserRole.CORE) && (meTeam == TeamType.HR); + if (isHrCore) { + if (current == UserRole.GUEST && newRole == UserRole.MEMBER) { + target.changeRole(UserRole.MEMBER); + // MEMBER는 팀 불가 → 자동 null + if (!isTeamAssignableRole(UserRole.MEMBER)) { + target.changeTeam(null); + } + userRepository.save(target); + return; + } + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "HR-CORE는 GUEST→MEMBER 변경만 가능"); + } + + // 일반 규칙: 나의 권한은 대상의 현재/신규 권한보다 엄격히 높아야 함 + boolean higherThanCurrent = meRole.rank() > current.rank(); + boolean higherThanNew = meRole.rank() > newRole.rank(); + if (!higherThanCurrent || !higherThanNew) { + throw new BusinessException(GlobalErrorCode.FORBIDDEN_USER, "요청한 역할 변경 권한이 없습니다."); + } + + target.changeRole(newRole); + // 역할이 팀 불가면 팀 자동 해제 + if (!isTeamAssignableRole(newRole)) { + target.changeTeam(null); + } + userRepository.save(target); + } + + /** + * 실제 반영 (역할 변경 후 역할 정책에 따라 팀도 정리) + */ + private void targetChange(User target, UserRole newRole, TeamType newTeam) { + target.changeRole(newRole); + if (!isTeamAssignableRole(newRole)) { + newTeam = null; // 방어적 정리 + } + target.changeTeam(newTeam); + userRepository.save(target); + } + + private User getEditor(CustomUserDetails editor) { + return userRepository.findById(editor.getUserId()) + .orElseThrow(() -> new BusinessException(GlobalErrorCode.UNAUTHORIZED_USER)); + } + + /** + * 팀을 가질 수 있는 역할만 true (CORE, LEAD) + */ + private boolean isTeamAssignableRole(UserRole role) { + return role == UserRole.CORE || role == UserRole.LEAD; + } +} \ No newline at end of file diff --git a/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java b/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java index c0263b3..124a9e8 100644 --- a/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java +++ b/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java @@ -1,19 +1,11 @@ package inha.gdgoc.global.config.jwt; -import static inha.gdgoc.global.exception.GlobalErrorCode.INVALID_JWT_REQUEST; - import inha.gdgoc.domain.auth.enums.LoginType; import inha.gdgoc.domain.user.entity.User; import inha.gdgoc.domain.user.enums.TeamType; import inha.gdgoc.domain.user.enums.UserRole; import inha.gdgoc.global.exception.BusinessException; import io.jsonwebtoken.*; -import java.time.Duration; -import java.util.Base64; -import java.util.Collection; -import java.util.Collections; -import java.util.Date; -import java.util.Set; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -22,9 +14,15 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Service; +import java.time.Duration; +import java.util.*; + +import static inha.gdgoc.global.exception.GlobalErrorCode.INVALID_JWT_REQUEST; + @RequiredArgsConstructor @Service public class TokenProvider { + private final JwtProperties jwtProperties; // 자체 로그인용 토큰 생성 @@ -44,8 +42,7 @@ public String generateRefreshToken(User user, Duration expiredAt, LoginType logi return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user, loginType); } - public Claims validToken(String token) throws ExpiredJwtException, UnsupportedJwtException, - MalformedJwtException, SignatureException, IllegalArgumentException { + public Claims validToken(String token) throws ExpiredJwtException, UnsupportedJwtException, MalformedJwtException, SignatureException, IllegalArgumentException { return getClaims(token); } @@ -62,32 +59,31 @@ public Authentication getAuthentication(String token) { String roleStr = claims.get("role", String.class); if (roleStr == null) throw new BusinessException(INVALID_JWT_REQUEST); UserRole userRole = UserRole.valueOf(roleStr); - String roleName = "ROLE_" + userRole.name(); - Set authorities = - Collections.singleton(new SimpleGrantedAuthority(roleName)); - // team (선택) - 토큰에는 enum name(String)으로 저장됨. null/오타는 무시. + // 권한 세트 구성 + Set authorities = new HashSet<>(); + // 1) 역할 권한 + authorities.add(new SimpleGrantedAuthority("ROLE_" + userRole.name())); + + // 2) 팀 권한 (선택) TeamType team = null; String teamStr = claims.get("team", String.class); if (teamStr != null && !teamStr.isBlank()) { try { team = TeamType.valueOf(teamStr); + authorities.add(new SimpleGrantedAuthority("TEAM_" + team.name())); } catch (IllegalArgumentException ignored) { - // 구버전 토큰 또는 잘못된 값이면 null 유지 } } - CustomUserDetails userDetails = - new CustomUserDetails(userId, username, "", authorities, userRole, team); + CustomUserDetails userDetails = new CustomUserDetails(userId, username, "", authorities, userRole, team); return new UsernamePasswordAuthenticationToken(userDetails, null, authorities); } private String makeToken(Date expiry, User user, LoginType loginType) { Date now = new Date(); - String issuer = (loginType == LoginType.SELF_SIGNUP) - ? jwtProperties.getSelfIssuer() - : jwtProperties.getGoogleIssuer(); + String issuer = (loginType == LoginType.SELF_SIGNUP) ? jwtProperties.getSelfIssuer() : jwtProperties.getGoogleIssuer(); // team: enum name 저장(예: "PR_DESIGN"), 없으면 null String teamEnumName = (user.getTeam() == null) ? null : user.getTeam().name(); @@ -102,8 +98,8 @@ private String makeToken(Date expiry, User user, LoginType loginType) { .claim("loginType", loginType.name()) .claim("role", user.getUserRole().name()) .claim("team", teamEnumName) - .signWith(SignatureAlgorithm.HS256, - Base64.getEncoder().encodeToString(jwtProperties.getSecretKey().getBytes())) + .signWith(SignatureAlgorithm.HS256, Base64.getEncoder() + .encodeToString(jwtProperties.getSecretKey().getBytes())) .compact(); } @@ -116,16 +112,12 @@ private Claims getClaims(String token) { @Getter public static class CustomUserDetails extends org.springframework.security.core.userdetails.User { + private final Long userId; private final UserRole role; private final TeamType team; - public CustomUserDetails(Long userId, - String username, - String password, - Collection authorities, - UserRole role, - TeamType team) { + public CustomUserDetails(Long userId, String username, String password, Collection authorities, UserRole role, TeamType team) { super(username, password, authorities); this.userId = userId; this.role = role; diff --git a/src/main/java/inha/gdgoc/global/exception/BusinessException.java b/src/main/java/inha/gdgoc/global/exception/BusinessException.java index e45c264..b700359 100644 --- a/src/main/java/inha/gdgoc/global/exception/BusinessException.java +++ b/src/main/java/inha/gdgoc/global/exception/BusinessException.java @@ -12,4 +12,8 @@ public BusinessException(ErrorCode errorCode) { this.errorCode = errorCode; } + public BusinessException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } }