diff --git a/src/main/java/kr/java/documind/domain/auth/controller/AuthApiController.java b/src/main/java/kr/java/documind/domain/auth/controller/AuthApiController.java index 5387004..4c9602f 100644 --- a/src/main/java/kr/java/documind/domain/auth/controller/AuthApiController.java +++ b/src/main/java/kr/java/documind/domain/auth/controller/AuthApiController.java @@ -3,15 +3,10 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.util.UUID; -import kr.java.documind.domain.auth.model.enums.GlobalRole; -import kr.java.documind.domain.member.model.entity.Member; -import kr.java.documind.domain.member.service.MemberService; +import kr.java.documind.domain.auth.service.AuthService; import kr.java.documind.global.config.JwtProperties; import kr.java.documind.global.response.ApiResponse; -import kr.java.documind.global.response.ErrorResponse; -import kr.java.documind.global.security.RedisTokenService; import kr.java.documind.global.security.jwt.CustomUserDetails; -import kr.java.documind.global.security.jwt.TokenProvider; import kr.java.documind.global.util.CookieUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -27,80 +22,19 @@ @RequiredArgsConstructor public class AuthApiController { - private final TokenProvider jwtProvider; + private final AuthService authService; private final JwtProperties jwtProperties; private final CookieUtil cookieUtil; - private final RedisTokenService redisTokenService; - private final MemberService memberService; @PostMapping("/refresh") public ResponseEntity> refresh( HttpServletRequest request, HttpServletResponse response) { - String refreshToken = - cookieUtil - .getCookieValue(request, jwtProperties.getRefreshCookieName()) - .orElse(null); - - if (refreshToken == null) { - return ResponseEntity.status(401) - .body(ApiResponse.error(ErrorResponse.of("Refresh Token이 없습니다."))); - } - - if (!jwtProvider.validateToken(refreshToken)) { - log.debug("만료 또는 유효하지 않은 Refresh Token"); - deleteAuthCookies(response); - return ResponseEntity.status(401) - .body( - ApiResponse.error( - ErrorResponse.of("Refresh Token이 만료되었습니다. 다시 로그인하세요."))); - } - - UUID memberId = jwtProvider.getMemberId(refreshToken); - GlobalRole globalRole = jwtProvider.getGlobalRole(refreshToken); - - String storedToken = redisTokenService.consumeRefreshToken(memberId); - if (!refreshToken.equals(storedToken)) { - log.warn("Refresh Token 불일치 — 탈취 가능성: memberId={}", memberId); - deleteAuthCookies(response); - return ResponseEntity.status(401) - .body( - ApiResponse.error( - ErrorResponse.of("유효하지 않은 Refresh Token입니다. 다시 로그인하세요."))); - } - - Member member = memberService.getMemberWithCompany(memberId); - if (!member.isActive()) { - log.warn( - "[AuthApiController] 비활성 계정의 토큰 갱신 시도: memberId={} status={}", - memberId, - member.getAccountStatus()); - deleteAuthCookies(response); - return ResponseEntity.status(401) - .body(ApiResponse.error(ErrorResponse.of("계정이 비활성화되었습니다. 다시 로그인하세요."))); - } - - String newAccessToken = jwtProvider.generateAccessToken(memberId, globalRole); - String newRefreshToken = jwtProvider.generateRefreshToken(memberId, globalRole); + String refreshToken = getCookieValue(request, jwtProperties.getRefreshCookieName()); - boolean secure = jwtProperties.isCookieSecure(); - cookieUtil.addCookie( - response, - jwtProperties.getAccessCookieName(), - newAccessToken, - jwtProperties.getAccessExpirationSeconds(), - secure); - cookieUtil.addCookie( - response, - jwtProperties.getRefreshCookieName(), - newRefreshToken, - jwtProperties.getRefreshExpirationSeconds(), - secure); - - redisTokenService.saveRefreshToken( - memberId, newRefreshToken, jwtProperties.getRefreshExpirationSeconds()); + AuthService.AuthTokens newTokens = authService.refresh(refreshToken); + addAuthCookies(response, newTokens); - log.debug("[AuthApiController] Access Token 재발급 완료: memberId={}", memberId); return ResponseEntity.ok(ApiResponse.success(null)); } @@ -110,46 +44,38 @@ public ResponseEntity> logout( HttpServletRequest request, HttpServletResponse response) { - if (authMember != null) { - String accessToken = - cookieUtil - .getCookieValue(request, jwtProperties.getAccessCookieName()) - .orElse(null); - - if (accessToken != null) { - long remainingMillis = jwtProvider.getRemainingMillis(accessToken); - if (remainingMillis > 0) { - redisTokenService.addToBlacklist(accessToken, remainingMillis); - } - } - - redisTokenService.deleteRefreshToken(authMember.getMemberId()); - log.info("[AuthApiController] 로그아웃: memberId={}", authMember.getMemberId()); - - } else { - String refreshToken = - cookieUtil - .getCookieValue(request, jwtProperties.getRefreshCookieName()) - .orElse(null); - - if (refreshToken != null) { - try { - UUID memberId = jwtProvider.getMemberIdFromExpiredToken(refreshToken); - redisTokenService.deleteRefreshToken(memberId); - log.info( - "[AuthApiController] 만료된 토큰으로 로그아웃 처리 (Refresh Token 정리): memberId={}", - memberId); - } catch (Exception e) { - log.debug( - "Refresh Token에서 memberId 추출 실패 (이미 정리되었거나 서명 오류): {}", e.getMessage()); - } - } - } + String accessToken = getCookieValue(request, jwtProperties.getAccessCookieName()); + String refreshToken = getCookieValue(request, jwtProperties.getRefreshCookieName()); + UUID memberId = authMember != null ? authMember.getMemberId() : null; + + authService.logout(accessToken, refreshToken, memberId); deleteAuthCookies(response); return ResponseEntity.ok(ApiResponse.success(null)); } + private String getCookieValue(HttpServletRequest request, String cookieName) { + return cookieUtil.getCookieValue(request, cookieName).orElse(null); + } + + private void addAuthCookies(HttpServletResponse response, AuthService.AuthTokens authTokens) { + boolean secure = jwtProperties.isCookieSecure(); + + cookieUtil.addCookie( + response, + jwtProperties.getAccessCookieName(), + authTokens.accessToken(), + jwtProperties.getAccessExpirationSeconds(), + secure); + + cookieUtil.addCookie( + response, + jwtProperties.getRefreshCookieName(), + authTokens.refreshToken(), + jwtProperties.getRefreshExpirationSeconds(), + secure); + } + private void deleteAuthCookies(HttpServletResponse response) { boolean secure = jwtProperties.isCookieSecure(); cookieUtil.deleteCookie(response, jwtProperties.getAccessCookieName(), secure); diff --git a/src/main/java/kr/java/documind/domain/auth/controller/AuthViewController.java b/src/main/java/kr/java/documind/domain/auth/controller/AuthViewController.java index 51c0dad..d64db41 100644 --- a/src/main/java/kr/java/documind/domain/auth/controller/AuthViewController.java +++ b/src/main/java/kr/java/documind/domain/auth/controller/AuthViewController.java @@ -3,18 +3,28 @@ import kr.java.documind.global.security.jwt.CustomUserDetails; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; @Controller @RequestMapping("/auth") public class AuthViewController { @GetMapping("/login") - public String loginPage(@AuthenticationPrincipal CustomUserDetails authMember) { + public String loginPage( + @AuthenticationPrincipal CustomUserDetails authMember, + @RequestParam(required = false) String flow, + @RequestParam(required = false) String redirect, + Model model) { + if (authMember != null) { return "redirect:/member/dashboard"; } + + model.addAttribute("isInviteFlow", "invite".equals(flow)); + model.addAttribute("redirectUrl", redirect); return "auth/login"; } } diff --git a/src/main/java/kr/java/documind/domain/auth/exception/AuthApiExceptionHandler.java b/src/main/java/kr/java/documind/domain/auth/exception/AuthApiExceptionHandler.java new file mode 100644 index 0000000..3005a7c --- /dev/null +++ b/src/main/java/kr/java/documind/domain/auth/exception/AuthApiExceptionHandler.java @@ -0,0 +1,51 @@ +package kr.java.documind.domain.auth.exception; + +import jakarta.servlet.http.HttpServletResponse; +import kr.java.documind.domain.auth.controller.AuthApiController; +import kr.java.documind.global.config.JwtProperties; +import kr.java.documind.global.exception.BusinessException; +import kr.java.documind.global.exception.ForbiddenException; +import kr.java.documind.global.exception.UnauthorizedException; +import kr.java.documind.global.response.ApiResponse; +import kr.java.documind.global.response.ErrorResponse; +import kr.java.documind.global.util.CookieUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice(assignableTypes = AuthApiController.class) +@RequiredArgsConstructor +public class AuthApiExceptionHandler { + + private final JwtProperties jwtProperties; + private final CookieUtil cookieUtil; + + @ExceptionHandler({UnauthorizedException.class, ForbiddenException.class}) + public ResponseEntity> handleAuthException( + BusinessException e, HttpServletResponse response) { + + deleteAuthCookies(response); + + return ResponseEntity.status(resolveStatus(e)) + .body(ApiResponse.error(ErrorResponse.of(e.getMessage()))); + } + + private int resolveStatus(BusinessException e) { + if (e instanceof UnauthorizedException) { + return HttpServletResponse.SC_UNAUTHORIZED; + } + if (e instanceof ForbiddenException) { + return HttpServletResponse.SC_FORBIDDEN; + } + return HttpServletResponse.SC_BAD_REQUEST; + } + + private void deleteAuthCookies(HttpServletResponse response) { + boolean secure = jwtProperties.isCookieSecure(); + cookieUtil.deleteCookie(response, jwtProperties.getAccessCookieName(), secure); + cookieUtil.deleteCookie(response, jwtProperties.getRefreshCookieName(), secure); + } +} diff --git a/src/main/java/kr/java/documind/domain/auth/exception/InvalidTokenException.java b/src/main/java/kr/java/documind/domain/auth/exception/InvalidTokenException.java new file mode 100644 index 0000000..8e38730 --- /dev/null +++ b/src/main/java/kr/java/documind/domain/auth/exception/InvalidTokenException.java @@ -0,0 +1,9 @@ +package kr.java.documind.domain.auth.exception; + +import kr.java.documind.global.exception.UnauthorizedException; + +public class InvalidTokenException extends UnauthorizedException { + public InvalidTokenException() { + super("유효하지 않은 토큰입니다."); + } +} diff --git a/src/main/java/kr/java/documind/domain/auth/exception/TokenExpiredException.java b/src/main/java/kr/java/documind/domain/auth/exception/TokenExpiredException.java new file mode 100644 index 0000000..ad18eb6 --- /dev/null +++ b/src/main/java/kr/java/documind/domain/auth/exception/TokenExpiredException.java @@ -0,0 +1,10 @@ +package kr.java.documind.domain.auth.exception; + +import kr.java.documind.global.exception.UnauthorizedException; + +public class TokenExpiredException extends UnauthorizedException { + + public TokenExpiredException() { + super("토큰이 만료되었습니다."); + } +} diff --git a/src/main/java/kr/java/documind/domain/auth/model/repository/ProjectRepository.java b/src/main/java/kr/java/documind/domain/auth/model/repository/ProjectRepository.java index eb3a7fb..4c88abc 100644 --- a/src/main/java/kr/java/documind/domain/auth/model/repository/ProjectRepository.java +++ b/src/main/java/kr/java/documind/domain/auth/model/repository/ProjectRepository.java @@ -12,6 +12,9 @@ public interface ProjectRepository extends JpaRepository { Optional findByPublicId(String publicId); + @Query("SELECT p FROM Project p JOIN FETCH p.company WHERE p.publicId = :publicId") + Optional findByPublicIdWithCompany(@Param("publicId") String publicId); + @Query( """ SELECT p.id AS projectId, diff --git a/src/main/java/kr/java/documind/domain/auth/service/AuthService.java b/src/main/java/kr/java/documind/domain/auth/service/AuthService.java new file mode 100644 index 0000000..d541352 --- /dev/null +++ b/src/main/java/kr/java/documind/domain/auth/service/AuthService.java @@ -0,0 +1,116 @@ +package kr.java.documind.domain.auth.service; + +import java.util.UUID; +import kr.java.documind.domain.auth.exception.InvalidTokenException; +import kr.java.documind.domain.auth.model.enums.GlobalRole; +import kr.java.documind.domain.member.model.entity.Member; +import kr.java.documind.domain.member.service.MemberService; +import kr.java.documind.global.config.JwtProperties; +import kr.java.documind.global.exception.ForbiddenException; +import kr.java.documind.global.exception.UnauthorizedException; +import kr.java.documind.global.security.RedisTokenService; +import kr.java.documind.global.security.jwt.TokenProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final TokenProvider tokenProvider; + private final RedisTokenService redisTokenService; + private final MemberService memberService; + private final JwtProperties jwtProperties; + + public AuthTokens refresh(String refreshToken) { + validateRefreshTokenPresence(refreshToken); + tokenProvider.validateRefreshToken(refreshToken); + + UUID memberId = tokenProvider.getMemberId(refreshToken); + GlobalRole globalRole = tokenProvider.getGlobalRole(refreshToken); + + validateActiveMember(memberId); + + String newRefreshToken = tokenProvider.generateRefreshToken(memberId, globalRole); + rotateRefreshToken(memberId, refreshToken, newRefreshToken); + + String newAccessToken = tokenProvider.generateAccessToken(memberId, globalRole); + + log.debug("[AuthService] 토큰 재발급 완료: memberId={}", memberId); + return new AuthTokens(newAccessToken, newRefreshToken); + } + + public void logout(String accessToken, String refreshToken, UUID memberId) { + if (memberId != null) { + logoutAuthenticatedMember(accessToken, memberId); + return; + } + + logoutWithRefreshToken(refreshToken); + } + + private void validateRefreshTokenPresence(String refreshToken) { + if (refreshToken == null || refreshToken.isBlank()) { + throw new UnauthorizedException("Refresh Token이 없습니다."); + } + } + + private void validateActiveMember(UUID memberId) { + Member member = memberService.getMemberWithCompany(memberId); + + if (!member.isActive()) { + redisTokenService.deleteRefreshToken(memberId); + + log.warn( + "[AuthService] 비활성 계정의 토큰 갱신 시도: memberId={} status={}", + memberId, + member.getAccountStatus()); + + throw new ForbiddenException("계정이 비활성화되었습니다. 다시 로그인하세요."); + } + } + + private void rotateRefreshToken(UUID memberId, String oldRefreshToken, String newRefreshToken) { + boolean rotated = + redisTokenService.rotateRefreshToken( + memberId, + oldRefreshToken, + newRefreshToken, + jwtProperties.getRefreshExpirationSeconds()); + + if (!rotated) { + log.warn("[AuthService] Refresh Token 교체 실패: memberId={}", memberId); + throw new UnauthorizedException("유효하지 않은 Refresh Token입니다. 다시 로그인하세요."); + } + } + + private void logoutAuthenticatedMember(String accessToken, UUID memberId) { + if (accessToken != null && !accessToken.isBlank()) { + long remainingMillis = tokenProvider.getRemainingMillis(accessToken); + if (remainingMillis > 0) { + redisTokenService.addToBlacklist(accessToken, remainingMillis); + } + } + + redisTokenService.deleteRefreshToken(memberId); + log.info("[AuthService] 로그아웃: memberId={}", memberId); + } + + private void logoutWithRefreshToken(String refreshToken) { + if (refreshToken == null || refreshToken.isBlank()) { + return; + } + + try { + UUID extractedMemberId = tokenProvider.getMemberIdAllowExpired(refreshToken); + redisTokenService.deleteRefreshToken(extractedMemberId); + log.info("[AuthService] Refresh Token 기반 로그아웃 처리: memberId={}", extractedMemberId); + } catch (InvalidTokenException e) { + log.debug("[AuthService] Refresh Token에서 memberId 추출 실패: {}", e.getMessage()); + } + } + + public record AuthTokens(String accessToken, String refreshToken) {} +} diff --git a/src/main/java/kr/java/documind/domain/member/controller/AdminViewController.java b/src/main/java/kr/java/documind/domain/member/controller/AdminViewController.java new file mode 100644 index 0000000..2db10ed --- /dev/null +++ b/src/main/java/kr/java/documind/domain/member/controller/AdminViewController.java @@ -0,0 +1,39 @@ +package kr.java.documind.domain.member.controller; + +import kr.java.documind.domain.auth.model.enums.GlobalRole; +import kr.java.documind.domain.member.service.CompanyService; +import kr.java.documind.domain.member.service.CompanyService.AdminPageData; +import kr.java.documind.global.security.jwt.CustomUserDetails; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/admin") +@RequiredArgsConstructor +public class AdminViewController { + + private final CompanyService companyService; + + @GetMapping("/companies") + public String companyAdmin(@AuthenticationPrincipal CustomUserDetails authMember, Model model) { + + if (authMember.getGlobalRole() != GlobalRole.ADMIN) { + return "redirect:/my/company"; + } + + AdminPageData pageData = companyService.getAdminCompanyPageData(authMember.getMemberId()); + + model.addAttribute("headerInfo", pageData.headerInfo()); + model.addAttribute("pending", pageData.pendingCompanies()); + model.addAttribute("approved", pageData.approvedCompanies()); + model.addAttribute("suspended", pageData.suspendedCompanies()); + model.addAttribute("pendingCount", pageData.pendingCount()); + model.addAttribute("approvedCount", pageData.approvedCount()); + model.addAttribute("suspendedCount", pageData.suspendedCount()); + return "member/company-admin"; + } +} diff --git a/src/main/java/kr/java/documind/domain/member/controller/InviteViewController.java b/src/main/java/kr/java/documind/domain/member/controller/InviteViewController.java index 43a4d23..3dc2590 100644 --- a/src/main/java/kr/java/documind/domain/member/controller/InviteViewController.java +++ b/src/main/java/kr/java/documind/domain/member/controller/InviteViewController.java @@ -1,6 +1,8 @@ package kr.java.documind.domain.member.controller; import jakarta.servlet.http.HttpServletResponse; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import kr.java.documind.domain.auth.exception.AlreadyProjectMemberException; import kr.java.documind.domain.member.exception.InvalidInviteTokenException; import kr.java.documind.domain.member.exception.InviteEmailMismatchException; @@ -38,14 +40,17 @@ public String showInvite( Model model) { if (auth == null) { + String redirectUrl = "/invite?token=" + token; cookieUtil.addCookie( response, HttpCookieOAuth2AuthorizationRequestRepository.REDIRECT_AFTER_LOGIN_COOKIE, - "/invite?token=" + token, + redirectUrl, 600L, jwtProperties.isCookieSecure()); - log.info("[InviteViewController] 미인증 접근 → 로그인 후 복귀 경로 저장: /invite?token=..."); - return "redirect:/auth/login"; + log.info("[InviteViewController] 미인증 접근 → 로그인 후 복귀 경로 저장: {}", redirectUrl); + + String encodedRedirect = URLEncoder.encode(redirectUrl, StandardCharsets.UTF_8); + return "redirect:/auth/login?flow=invite&redirect=" + encodedRedirect; } try { @@ -60,10 +65,15 @@ public String showInvite( } catch (AlreadyProjectMemberException e) { return "redirect:/projects/" + e.getProjectPublicId() + "/groups"; - } catch (InvalidInviteTokenException | InviteEmailMismatchException e) { + } catch (InviteEmailMismatchException e) { + model.addAttribute( + "errorMessage", + "초대받은 이메일(" + e.getExpectedEmail() + ")과 현재 로그인된 계정의 이메일이 다릅니다."); + model.addAttribute("mismatch", true); + return "member/invite-error"; + } catch (InvalidInviteTokenException e) { model.addAttribute("errorMessage", e.getMessage()); return "member/invite-error"; - } catch (BusinessException e) { // 삭제된 프로젝트 등 기타 BusinessException model.addAttribute("errorMessage", e.getMessage()); diff --git a/src/main/java/kr/java/documind/domain/member/controller/MyPageViewController.java b/src/main/java/kr/java/documind/domain/member/controller/MyPageViewController.java index 01fa8f1..232d060 100644 --- a/src/main/java/kr/java/documind/domain/member/controller/MyPageViewController.java +++ b/src/main/java/kr/java/documind/domain/member/controller/MyPageViewController.java @@ -4,8 +4,6 @@ import kr.java.documind.domain.auth.model.dto.HeaderInfo; import kr.java.documind.domain.auth.model.enums.GlobalRole; import kr.java.documind.domain.member.model.dto.ProjectSummary; -import kr.java.documind.domain.member.service.CompanyService; -import kr.java.documind.domain.member.service.CompanyService.AdminPageData; import kr.java.documind.domain.member.service.MemberService; import kr.java.documind.domain.member.service.MemberService.CompanyPageData; import kr.java.documind.domain.member.service.MemberService.ProfilePageData; @@ -24,7 +22,6 @@ public class MyPageViewController { private final MemberService memberService; - private final CompanyService companyService; private final ProjectService projectService; @GetMapping("/profile") @@ -41,7 +38,7 @@ public String profile(@AuthenticationPrincipal CustomUserDetails authMember, Mod public String company(@AuthenticationPrincipal CustomUserDetails authMember, Model model) { if (authMember.getGlobalRole() == GlobalRole.ADMIN) { - return "redirect:/my/company/admin"; + return "redirect:/admin/companies"; } CompanyPageData pageData = memberService.getCompanyPageData(authMember.getMemberId()); @@ -51,25 +48,6 @@ public String company(@AuthenticationPrincipal CustomUserDetails authMember, Mod return "member/company"; } - @GetMapping("/company/admin") - public String companyAdmin(@AuthenticationPrincipal CustomUserDetails authMember, Model model) { - - if (authMember.getGlobalRole() != GlobalRole.ADMIN) { - return "redirect:/my/company"; - } - - AdminPageData pageData = companyService.getAdminCompanyPageData(authMember.getMemberId()); - - model.addAttribute("headerInfo", pageData.headerInfo()); - model.addAttribute("pending", pageData.pendingCompanies()); - model.addAttribute("approved", pageData.approvedCompanies()); - model.addAttribute("suspended", pageData.suspendedCompanies()); - model.addAttribute("pendingCount", pageData.pendingCount()); - model.addAttribute("approvedCount", pageData.approvedCount()); - model.addAttribute("suspendedCount", pageData.suspendedCount()); - return "member/company-admin"; - } - @GetMapping("/projects") public String projects(@AuthenticationPrincipal CustomUserDetails authMember, Model model) { diff --git a/src/main/java/kr/java/documind/domain/member/controller/ProjectApiController.java b/src/main/java/kr/java/documind/domain/member/controller/ProjectApiController.java index 0150e6b..3d71fe7 100644 --- a/src/main/java/kr/java/documind/domain/member/controller/ProjectApiController.java +++ b/src/main/java/kr/java/documind/domain/member/controller/ProjectApiController.java @@ -3,12 +3,14 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import java.util.UUID; import kr.java.documind.domain.auth.model.dto.ProjectRequestContext; import kr.java.documind.domain.member.model.dto.ApiKeyIssueResponse; import kr.java.documind.domain.member.model.dto.ApiKeyStatusUpdateRequest; import kr.java.documind.domain.member.model.dto.ProfileImageResponse; import kr.java.documind.domain.member.model.dto.ProjectCreateRequest; import kr.java.documind.domain.member.model.dto.ProjectCreateResponse; +import kr.java.documind.domain.member.model.dto.ProjectRoleUpdateRequest; import kr.java.documind.domain.member.model.dto.ProjectUpdateRequest; import kr.java.documind.domain.member.service.ProjectService; import kr.java.documind.global.annotation.CurrentProject; @@ -23,6 +25,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -74,6 +77,29 @@ public ResponseEntity> uploadProfileImage( return ResponseEntity.ok(ApiResponse.success(response)); } + @Operation(summary = "프로젝트 멤버 권한 변경", description = "프로젝트 멤버의 권한을 변경합니다. MANAGER 권한이 필요합니다.") + @RequireProjectManager + @PatchMapping("/{publicId}/members/{memberId}/role") + public ResponseEntity> changeProjectMemberRole( + @CurrentProject ProjectRequestContext ctx, + @PathVariable UUID memberId, + @Valid @RequestBody ProjectRoleUpdateRequest request) { + + projectService.changeProjectRole( + ctx.publicId(), ctx.actorMemberId(), memberId, request.role()); + return ResponseEntity.ok(ApiResponse.success("멤버 권한이 변경되었습니다.")); + } + + @Operation(summary = "프로젝트 멤버 제거", description = "프로젝트에서 특정 멤버를 제거합니다. MANAGER 권한이 필요합니다.") + @RequireProjectManager + @DeleteMapping("/{publicId}/members/{memberId}") + public ResponseEntity> removeProjectMember( + @CurrentProject ProjectRequestContext ctx, @PathVariable UUID memberId) { + + projectService.removeProjectMember(ctx.publicId(), ctx.actorMemberId(), memberId); + return ResponseEntity.ok(ApiResponse.success("멤버를 제거했습니다.")); + } + @Operation(summary = "프로젝트 나가기", description = "현재 사용자를 프로젝트 멤버에서 제거합니다. CEO는 나갈 수 없습니다.") @RequireProjectMember @DeleteMapping("/{publicId}/members/me") diff --git a/src/main/java/kr/java/documind/domain/member/model/dto/ProjectDetail.java b/src/main/java/kr/java/documind/domain/member/model/dto/ProjectDetail.java deleted file mode 100644 index 8378e3d..0000000 --- a/src/main/java/kr/java/documind/domain/member/model/dto/ProjectDetail.java +++ /dev/null @@ -1,3 +0,0 @@ -package kr.java.documind.domain.member.model.dto; - -public record ProjectDetail(String publicId, String name, String profileUrl) {} diff --git a/src/main/java/kr/java/documind/domain/member/model/dto/ProjectRoleUpdateRequest.java b/src/main/java/kr/java/documind/domain/member/model/dto/ProjectRoleUpdateRequest.java new file mode 100644 index 0000000..4bbd76a --- /dev/null +++ b/src/main/java/kr/java/documind/domain/member/model/dto/ProjectRoleUpdateRequest.java @@ -0,0 +1,6 @@ +package kr.java.documind.domain.member.model.dto; + +import jakarta.validation.constraints.NotNull; +import kr.java.documind.domain.auth.model.enums.ProjectRole; + +public record ProjectRoleUpdateRequest(@NotNull ProjectRole role) {} diff --git a/src/main/java/kr/java/documind/domain/member/model/dto/ProjectSettingPageData.java b/src/main/java/kr/java/documind/domain/member/model/dto/ProjectSettingPageData.java index 55b868c..f4cbf73 100644 --- a/src/main/java/kr/java/documind/domain/member/model/dto/ProjectSettingPageData.java +++ b/src/main/java/kr/java/documind/domain/member/model/dto/ProjectSettingPageData.java @@ -6,7 +6,7 @@ public record ProjectSettingPageData( HeaderInfo headerInfo, - ProjectDetail project, + ProjectSummary project, ProjectRole currentRole, boolean isCeo, List members, diff --git a/src/main/java/kr/java/documind/domain/member/model/repository/ProjectMemberRepository.java b/src/main/java/kr/java/documind/domain/member/model/repository/ProjectMemberRepository.java index e0a78fa..823a31f 100644 --- a/src/main/java/kr/java/documind/domain/member/model/repository/ProjectMemberRepository.java +++ b/src/main/java/kr/java/documind/domain/member/model/repository/ProjectMemberRepository.java @@ -4,6 +4,7 @@ import java.util.Optional; import java.util.UUID; import kr.java.documind.domain.auth.model.entity.Project; +import kr.java.documind.domain.auth.model.enums.ProjectRole; import kr.java.documind.domain.member.model.entity.Member; import kr.java.documind.domain.member.model.entity.ProjectMember; import kr.java.documind.domain.member.model.enums.AccountStatus; @@ -13,10 +14,8 @@ public interface ProjectMemberRepository extends JpaRepository { - /** 특정 프로젝트에서 회원의 멤버십을 조회한다. */ Optional findByProjectAndMember(Project project, Member member); - /** 프로젝트에 속한 활성 멤버를 회원 정보와 함께 JOIN FETCH 조회한다. (LazyInitializationException 방지) */ @Query( """ SELECT pm FROM ProjectMember pm @@ -27,7 +26,6 @@ public interface ProjectMemberRepository extends JpaRepository findByProjectAndStatusFetchMember( @Param("project") Project project, @Param("status") AccountStatus status); - /** 회원이 참여 중인 활성 멤버십을 프로젝트 정보와 함께 JOIN FETCH 조회한다. (사이드바 프로젝트 드롭다운용) */ @Query( """ SELECT pm FROM ProjectMember pm @@ -38,12 +36,6 @@ List findByProjectAndStatusFetchMember( List findByMemberAndStatusFetchProject( @Param("member") Member member, @Param("status") AccountStatus status); - /** - * 회원 ID로 참여 중인 활성 멤버십을 프로젝트 정보와 함께 JOIN FETCH 조회한다. - * - *

{@link #findByMemberAndStatusFetchProject}의 UUID 오버로드. Member 엔티티 로드 없이 조회하여 불필요한 DB 조회를 - * 제거한다. - */ @Query( """ SELECT pm FROM ProjectMember pm @@ -54,6 +46,8 @@ List findByMemberAndStatusFetchProject( List findByMemberIdAndStatusFetchProject( @Param("memberId") UUID memberId, @Param("status") AccountStatus status); - /** 프로젝트에 속한 삭제되지 않은(ACTIVE/SUSPENDED) 모든 멤버를 조회한다. (프로젝트 삭제 시 일괄 소프트 딜리트용) */ List findAllByProjectAndStatusNot(Project project, AccountStatus status); + + long countByProjectAndProjectRoleAndStatus( + Project project, ProjectRole projectRole, AccountStatus status); } diff --git a/src/main/java/kr/java/documind/domain/member/service/InvitationService.java b/src/main/java/kr/java/documind/domain/member/service/InvitationService.java index 8ef2d91..9215546 100644 --- a/src/main/java/kr/java/documind/domain/member/service/InvitationService.java +++ b/src/main/java/kr/java/documind/domain/member/service/InvitationService.java @@ -128,17 +128,33 @@ public InviteViewData getInviteViewData(String rawToken, UUID memberId) { @Transactional public String acceptInvitation(String rawToken, UUID memberId, boolean forceLeaveCompany) { + log.info( + "[InvitationService] acceptInvitation 호출 시작: tokenHashPrefix={}..., memberId={}", + rawToken.substring(0, Math.min(5, rawToken.length())), + memberId); + InviteResolution resolution = resolveToken(rawToken); Invitation invitation = resolution.invitation(); String tokenHash = resolution.tokenHash(); - Project project = invitation.getProject(); + + String publicId = invitation.getProject().getPublicId(); + Project project = + projectRepository + .findByPublicIdWithCompany(publicId) + .orElseThrow(ProjectNotFoundException::new); validateProjectNotDeleted(project); Member member = memberService.getMemberWithCompany(memberId); validateMemberEmailMatch(member, invitation.getTargetEmail()); - if (checkDifferentCompany(member, project)) { + if (member.getCompany() == null) { + member.assignCompany(project.getCompany()); + log.info( + "[InvitationService] 회사 할당: memberId={} company={}", + member.getId(), + project.getCompany().getName()); + } else if (checkDifferentCompany(member, project)) { handleCompanySwitch(member, project.getCompany(), forceLeaveCompany); } @@ -156,8 +172,6 @@ public String acceptInvitation(String rawToken, UUID memberId, boolean forceLeav return project.getPublicId(); } - // ── Helper Methods ───────────────────────────────────────────────────────────── - private void validateInvitationRequest(Member inviter, Project project, String targetEmail) { if (inviter.getEmail() != null && inviter.getEmail().equalsIgnoreCase(targetEmail)) { throw new BadRequestException("자신에게 초대를 보낼 수 없습니다."); diff --git a/src/main/java/kr/java/documind/domain/member/service/ProjectService.java b/src/main/java/kr/java/documind/domain/member/service/ProjectService.java index 163e96a..503939c 100644 --- a/src/main/java/kr/java/documind/domain/member/service/ProjectService.java +++ b/src/main/java/kr/java/documind/domain/member/service/ProjectService.java @@ -15,7 +15,6 @@ import kr.java.documind.domain.member.model.dto.ProfileImageResponse; import kr.java.documind.domain.member.model.dto.ProjectApiKeyInfo; import kr.java.documind.domain.member.model.dto.ProjectCreateResponse; -import kr.java.documind.domain.member.model.dto.ProjectDetail; import kr.java.documind.domain.member.model.dto.ProjectMemberRow; import kr.java.documind.domain.member.model.dto.ProjectSettingPageData; import kr.java.documind.domain.member.model.dto.ProjectSummary; @@ -143,8 +142,8 @@ public ProjectSettingPageData getProjectSettingPageData(String publicId, UUID me project.getProfileKey() != null ? fileStore.getAccessUrl(project.getProfileKey()) : null; - var projectDetail = - new ProjectDetail(project.getPublicId(), project.getName(), projectProfileUrl); + var projectSummary = + new ProjectSummary(project.getPublicId(), project.getName(), projectProfileUrl); List members = projectMemberRepository @@ -203,7 +202,7 @@ public ProjectSettingPageData getProjectSettingPageData(String publicId, UUID me return new ProjectSettingPageData( headerInfo, - projectDetail, + projectSummary, currentPm.getProjectRole(), currentMember.isCeo(), members, @@ -243,6 +242,88 @@ public void updateProjectName(String publicId, UUID memberId, String name) { name); } + @Transactional + public void changeProjectRole( + String publicId, UUID actorMemberId, UUID targetMemberId, ProjectRole newRole) { + Project project = + projectRepository + .findByPublicId(publicId) + .orElseThrow(ProjectNotFoundException::new); + + Member targetMember = memberService.getMember(targetMemberId); + + if (targetMember.isCeo()) { + throw new ForbiddenException("대표(CEO)의 프로젝트 권한은 변경할 수 없습니다."); + } + + ProjectMember targetPm = + projectMemberRepository + .findByProjectAndMember(project, targetMember) + .orElseThrow(() -> new NotFoundException("대상이 프로젝트 멤버가 아닙니다.")); + + if (targetPm.getStatus() == AccountStatus.DELETED) { + throw new NotFoundException("대상이 프로젝트 멤버가 아닙니다."); + } + + if (targetPm.isManager() && newRole == ProjectRole.MEMBER) { + if (projectMemberRepository.countByProjectAndProjectRoleAndStatus( + project, ProjectRole.MANAGER, AccountStatus.ACTIVE) + <= 1) { + throw new BadRequestException("프로젝트에는 최소 한 명 이상의 관리자가 필요합니다."); + } + } + + targetPm.changeRole(newRole); + log.info( + "[ProjectService] 멤버 권한 변경: actorId={}, targetId={}, projectId={}, newRole={}", + actorMemberId, + targetMemberId, + project.getId(), + newRole); + } + + @Transactional + public void removeProjectMember(String publicId, UUID actorMemberId, UUID targetMemberId) { + if (actorMemberId.equals(targetMemberId)) { + throw new BadRequestException("자기 자신을 제거할 수 없습니다. '프로젝트 나가기'를 이용해주세요."); + } + + Project project = + projectRepository + .findByPublicId(publicId) + .orElseThrow(ProjectNotFoundException::new); + + Member targetMember = memberService.getMember(targetMemberId); + + if (targetMember.isCeo()) { + throw new ForbiddenException("대표(CEO)는 프로젝트에서 제거할 수 없습니다."); + } + + ProjectMember targetPm = + projectMemberRepository + .findByProjectAndMember(project, targetMember) + .orElseThrow(() -> new NotFoundException("대상이 프로젝트 멤버가 아닙니다.")); + + if (targetPm.getStatus() == AccountStatus.DELETED) { + throw new NotFoundException("대상이 프로젝트 멤버가 아닙니다."); + } + + if (targetPm.isManager()) { + if (projectMemberRepository.countByProjectAndProjectRoleAndStatus( + project, ProjectRole.MANAGER, AccountStatus.ACTIVE) + <= 1) { + throw new BadRequestException("프로젝트에는 최소 한 명 이상의 관리자가 필요합니다."); + } + } + + targetPm.softDelete(); + log.info( + "[ProjectService] 멤버 제거: actorId={}, targetId={}, projectId={}", + actorMemberId, + targetMemberId, + project.getId()); + } + @Transactional public void leaveProject(String publicId, UUID memberId) { Project project = @@ -261,6 +342,14 @@ public void leaveProject(String publicId, UUID memberId) { .findByProjectAndMember(project, member) .orElseThrow(() -> new NotFoundException("프로젝트 멤버를 찾을 수 없습니다.")); + if (pm.isManager()) { + if (projectMemberRepository.countByProjectAndProjectRoleAndStatus( + project, ProjectRole.MANAGER, AccountStatus.ACTIVE) + <= 1) { + throw new BadRequestException("프로젝트에는 최소 한 명 이상의 관리자가 필요합니다. 나갈 수 없습니다."); + } + } + pm.softDelete(); log.info("[ProjectService] 프로젝트 나가기: memberId={} publicId={}", memberId, publicId); } diff --git a/src/main/java/kr/java/documind/global/config/RedisConfig.java b/src/main/java/kr/java/documind/global/config/RedisConfig.java index 76924f0..b753f21 100644 --- a/src/main/java/kr/java/documind/global/config/RedisConfig.java +++ b/src/main/java/kr/java/documind/global/config/RedisConfig.java @@ -4,6 +4,8 @@ import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.data.redis.core.script.RedisScript; @Configuration public class RedisConfig { @@ -12,4 +14,29 @@ public class RedisConfig { public StringRedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { return new StringRedisTemplate(connectionFactory); } + + @Bean + public RedisScript rotateRefreshTokenScript() { + + String script = + """ + local current = redis.call('GET', KEYS[1]) + if current == false then + return 0 + end + + if current ~= ARGV[1] then + return 0 + end + + redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3]) + return 1 + """; + + DefaultRedisScript redisScript = new DefaultRedisScript<>(); + redisScript.setScriptText(script); + redisScript.setResultType(Long.class); + + return redisScript; + } } diff --git a/src/main/java/kr/java/documind/global/config/SecurityConfig.java b/src/main/java/kr/java/documind/global/config/SecurityConfig.java index 62057f3..5fb1e33 100644 --- a/src/main/java/kr/java/documind/global/config/SecurityConfig.java +++ b/src/main/java/kr/java/documind/global/config/SecurityConfig.java @@ -44,7 +44,7 @@ public class SecurityConfig { private static final String[] PUBLIC_GET_PATHS = { "/", "/auth/login", - "/invite/**", + "/invite/**", // /invite/{token} 페이지 접근 허용 "/error", "/css/**", "/js/**", @@ -72,11 +72,15 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .csrfTokenRequestHandler( new CsrfTokenRequestAttributeHandler()) .ignoringRequestMatchers( - "/oauth2/**", "/login/oauth2/**", "/api/**")) + "/oauth2/**", + "/login/oauth2/**", + "/api/**", + "/invite/accept")) .headers(headers -> headers.frameOptions(frame -> frame.sameOrigin())) .authorizeHttpRequests( auth -> - auth + auth.requestMatchers(HttpMethod.POST, "/invite/accept") + .authenticated() // ── 공개 페이지 / 정적 리소스 ────────────────────── .requestMatchers(HttpMethod.GET, PUBLIC_GET_PATHS) .permitAll() @@ -86,6 +90,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .permitAll() .requestMatchers("/actuator/**") .hasRole("ADMIN") + // ── OAuth2 관련 경로 ────────────────────────────── + .requestMatchers("/oauth2/**", "/login/oauth2/**") + .permitAll() // ── 어드민 전용 페이지 및 API ─────────────────────── .requestMatchers("/admin/**", "/api/admin/**") .hasRole("ADMIN") diff --git a/src/main/java/kr/java/documind/global/security/RedisTokenService.java b/src/main/java/kr/java/documind/global/security/RedisTokenService.java index 1c931c0..6ac6fc2 100644 --- a/src/main/java/kr/java/documind/global/security/RedisTokenService.java +++ b/src/main/java/kr/java/documind/global/security/RedisTokenService.java @@ -1,16 +1,19 @@ package kr.java.documind.global.security; +import java.util.Collections; import java.util.UUID; import java.util.concurrent.TimeUnit; import kr.java.documind.global.security.jwt.TokenProvider; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor public class RedisTokenService { + private static final String REFRESH_PREFIX = "refresh:"; private static final String BLACKLIST_PREFIX = "blacklist:"; private static final String OAUTH2_STATE_PREFIX = "oauth2_state:"; @@ -18,6 +21,7 @@ public class RedisTokenService { private final StringRedisTemplate redisTemplate; private final TokenProvider tokenProvider; + private final RedisScript rotateRefreshTokenScript; @Value("${app.jwt.redis-prefix:jwt:}") private String jwtPrefix; @@ -32,8 +36,21 @@ public String getRefreshToken(UUID memberId) { return redisTemplate.opsForValue().get(refreshKey(memberId)); } - public String consumeRefreshToken(UUID memberId) { - return redisTemplate.opsForValue().getAndDelete(refreshKey(memberId)); + public boolean rotateRefreshToken( + UUID memberId, + String expectedOldRefreshToken, + String newRefreshToken, + long ttlSeconds) { + + Long result = + redisTemplate.execute( + rotateRefreshTokenScript, + Collections.singletonList(refreshKey(memberId)), + expectedOldRefreshToken, + newRefreshToken, + String.valueOf(ttlSeconds)); + + return Long.valueOf(1L).equals(result); } public void deleteRefreshToken(UUID memberId) { @@ -42,16 +59,17 @@ public void deleteRefreshToken(UUID memberId) { public void addToBlacklist(String accessToken, long ttlMillis) { if (ttlMillis <= 0) { - return; // 이미 만료된 토큰은 블랙리스트 등록 불필요 + return; } - long ttlSeconds = Math.max(1L, ttlMillis / 1000); + + long ttlSeconds = Math.max(1L, (ttlMillis + 999L) / 1000L); redisTemplate .opsForValue() .set(blacklistKey(accessToken), "1", ttlSeconds, TimeUnit.SECONDS); } public boolean isBlacklisted(String accessToken) { - return redisTemplate.hasKey(blacklistKey(accessToken)); + return Boolean.TRUE.equals(redisTemplate.hasKey(blacklistKey(accessToken))); } public void revokeAllTokensByMember(UUID memberId, long accessTokenTtlSeconds) { diff --git a/src/main/java/kr/java/documind/global/security/filter/JwtAuthenticationFilter.java b/src/main/java/kr/java/documind/global/security/filter/JwtAuthenticationFilter.java index dd0bdba..f3b9839 100644 --- a/src/main/java/kr/java/documind/global/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/kr/java/documind/global/security/filter/JwtAuthenticationFilter.java @@ -10,6 +10,7 @@ import java.util.UUID; import kr.java.documind.domain.auth.model.enums.GlobalRole; import kr.java.documind.global.config.JwtProperties; +import kr.java.documind.global.exception.UnauthorizedException; import kr.java.documind.global.security.RedisTokenService; import kr.java.documind.global.security.jwt.CustomUserDetails; import kr.java.documind.global.security.jwt.TokenProvider; @@ -33,41 +34,54 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - String token = extractToken(request); - - if (StringUtils.hasText(token) - && tokenProvider.validateToken(token) - && tokenProvider.isAccessToken(token)) { - if (redisTokenService.isBlacklisted(token)) { - SecurityContextHolder.clearContext(); - log.debug( - "[JWT] Blacklisted JWT — cleared SecurityContext for {}", - request.getRequestURI()); - } else { - UUID memberId = tokenProvider.getMemberId(token); - if (redisTokenService.isMemberSuspended(memberId)) { - SecurityContextHolder.clearContext(); - log.warn( - "[JWT] 정지된 계정 요청 차단: memberId={} uri={}", - memberId, - request.getRequestURI()); - } else { - setAuthentication(request, token); - } - } - } else if (StringUtils.hasText(token)) { + + String accessToken = extractToken(request); + + if (!StringUtils.hasText(accessToken)) { + filterChain.doFilter(request, response); + return; + } + + try { + authenticateIfValidAccessToken(request, accessToken); + } catch (UnauthorizedException | IllegalArgumentException e) { SecurityContextHolder.clearContext(); - log.debug("[JWT] 만료되거나 유효하지 않은 JWT: SecurityContext 초기화 - {}", request.getRequestURI()); + log.warn( + "[JWT] 유효하지 않은 JWT: SecurityContext 초기화 - {} ({})", + request.getRequestURI(), + e.getMessage()); } filterChain.doFilter(request, response); } + private void authenticateIfValidAccessToken(HttpServletRequest request, String accessToken) { + tokenProvider.validateAccessToken(accessToken); + + if (redisTokenService.isBlacklisted(accessToken)) { + SecurityContextHolder.clearContext(); + log.debug( + "[JWT] Blacklisted JWT — cleared SecurityContext for {}", + request.getRequestURI()); + return; + } + + UUID memberId = tokenProvider.getMemberId(accessToken); + if (redisTokenService.isMemberSuspended(memberId)) { + SecurityContextHolder.clearContext(); + log.warn("[JWT] 정지된 계정 요청 차단: memberId={} uri={}", memberId, request.getRequestURI()); + return; + } + + setAuthentication(request, accessToken, memberId); + } + private String extractToken(HttpServletRequest request) { - String bearer = request.getHeader("Authorization"); - if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) { - return bearer.substring(7); + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); } + return extractTokenFromCookie(request); } @@ -85,21 +99,16 @@ private String extractTokenFromCookie(HttpServletRequest request) { .orElse(null); } - private void setAuthentication(HttpServletRequest request, String token) { - try { - UUID memberId = tokenProvider.getMemberId(token); - GlobalRole globalRole = tokenProvider.getGlobalRole(token); - - CustomUserDetails authMember = new CustomUserDetails(memberId, globalRole); - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken( - authMember, null, authMember.getAuthorities()); - - authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authentication); - } catch (Exception e) { - log.warn("[Jwt] JWT 클레임에서 CustomUserDetail 생성 실패: {} ", e.getMessage()); - SecurityContextHolder.clearContext(); - } + private void setAuthentication(HttpServletRequest request, String accessToken, UUID memberId) { + + GlobalRole globalRole = tokenProvider.getGlobalRole(accessToken); + + CustomUserDetails authMember = new CustomUserDetails(memberId, globalRole); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + authMember, null, authMember.getAuthorities()); + + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); } } diff --git a/src/main/java/kr/java/documind/global/security/jwt/TokenProvider.java b/src/main/java/kr/java/documind/global/security/jwt/TokenProvider.java index 992e1e8..3be996a 100644 --- a/src/main/java/kr/java/documind/global/security/jwt/TokenProvider.java +++ b/src/main/java/kr/java/documind/global/security/jwt/TokenProvider.java @@ -10,6 +10,8 @@ import java.util.Date; import java.util.UUID; import javax.crypto.SecretKey; +import kr.java.documind.domain.auth.exception.InvalidTokenException; +import kr.java.documind.domain.auth.exception.TokenExpiredException; import kr.java.documind.domain.auth.model.enums.GlobalRole; import kr.java.documind.global.config.JwtProperties; import lombok.RequiredArgsConstructor; @@ -22,9 +24,10 @@ public class TokenProvider { private static final String CLAIM_ROLE = "role"; + private static final String CLAIM_TOKEN_TYPE = "type"; + private static final String TOKEN_TYPE_ACCESS = "access"; private static final String TOKEN_TYPE_REFRESH = "refresh"; - private static final String CLAIM_TOKEN_TYPE = "type"; private final JwtProperties jwtProperties; @@ -37,97 +40,136 @@ public void init() { } public String generateAccessToken(UUID memberId, GlobalRole globalRole) { - Date now = new Date(); - Date expiry = new Date(now.getTime() + jwtProperties.getAccessExpirationSeconds() * 1000L); - - return Jwts.builder() - .id(UUID.randomUUID().toString()) - .subject(memberId.toString()) - .claim(CLAIM_ROLE, globalRole.name()) - .claim(CLAIM_TOKEN_TYPE, TOKEN_TYPE_ACCESS) - .issuedAt(now) - .expiration(expiry) - .signWith(secretKey) - .compact(); + return generateToken( + memberId, + globalRole, + TOKEN_TYPE_ACCESS, + jwtProperties.getAccessExpirationSeconds()); } public String generateRefreshToken(UUID memberId, GlobalRole globalRole) { - Date now = new Date(); - Date expiry = new Date(now.getTime() + jwtProperties.getRefreshExpirationSeconds() * 1000L); + return generateToken( + memberId, + globalRole, + TOKEN_TYPE_REFRESH, + jwtProperties.getRefreshExpirationSeconds()); + } - return Jwts.builder() - .id(UUID.randomUUID().toString()) - .subject(memberId.toString()) - .claim(CLAIM_ROLE, globalRole.name()) - .claim(CLAIM_TOKEN_TYPE, TOKEN_TYPE_REFRESH) - .issuedAt(now) - .expiration(expiry) - .signWith(secretKey) - .compact(); + public void validateRefreshToken(String token) { + Claims claims = parseValidClaims(token); + + String tokenType = claims.get(CLAIM_TOKEN_TYPE, String.class); + if (!TOKEN_TYPE_REFRESH.equals(tokenType)) { + throw new InvalidTokenException(); + } } - public String getTokenId(String token) { + public void validateAccessToken(String token) { + Claims claims = parseValidClaims(token); + + String tokenType = claims.get(CLAIM_TOKEN_TYPE, String.class); + if (!TOKEN_TYPE_ACCESS.equals(tokenType)) { + throw new InvalidTokenException(); + } + } + + public UUID getMemberId(String token) { try { - return parseClaims(token).getId(); + return UUID.fromString(parseClaims(token).getSubject()); } catch (ExpiredJwtException e) { - return e.getClaims().getId(); + throw new TokenExpiredException(); + } catch (JwtException | IllegalArgumentException e) { + throw new InvalidTokenException(); } } - public UUID getMemberId(String token) { - return UUID.fromString(parseClaims(token).getSubject()); + public UUID getMemberIdAllowExpired(String token) { + try { + return UUID.fromString(parseClaims(token).getSubject()); + } catch (ExpiredJwtException e) { + return UUID.fromString(e.getClaims().getSubject()); + } catch (JwtException | IllegalArgumentException e) { + throw new InvalidTokenException(); + } } public GlobalRole getGlobalRole(String token) { - String role = parseClaims(token).get(CLAIM_ROLE, String.class); - return GlobalRole.valueOf(role); + try { + String role = parseClaims(token).get(CLAIM_ROLE, String.class); + return GlobalRole.valueOf(role); + } catch (ExpiredJwtException e) { + throw new TokenExpiredException(); + } catch (JwtException | IllegalArgumentException e) { + throw new InvalidTokenException(); + } } - public Date getExpiration(String token) { - return parseClaims(token).getExpiration(); + public String getTokenId(String token) { + try { + return parseClaims(token).getId(); + } catch (ExpiredJwtException e) { + return e.getClaims().getId(); + } catch (JwtException | IllegalArgumentException e) { + throw new InvalidTokenException(); + } } public long getRemainingMillis(String token) { try { - return getExpiration(token).getTime() - System.currentTimeMillis(); + Date expiration = parseClaims(token).getExpiration(); + return expiration.getTime() - System.currentTimeMillis(); } catch (ExpiredJwtException e) { return e.getClaims().getExpiration().getTime() - System.currentTimeMillis(); - } catch (JwtException e) { + } catch (JwtException | IllegalArgumentException e) { log.warn("[JWT] 토큰 만료 시간 추출 실패: {}", e.getMessage()); return 0L; } } - public boolean validateToken(String token) { - try { - parseClaims(token); - return true; - } catch (ExpiredJwtException e) { - log.debug("[JWT] 토큰 만료: {}", e.getMessage()); - return false; - } catch (JwtException | IllegalArgumentException e) { - log.warn("[JWT] 토큰 검증 실패: {}", e.getMessage()); - return false; - } + public boolean isAccessToken(String token) { + return hasTokenType(token, TOKEN_TYPE_ACCESS); } - public UUID getMemberIdFromExpiredToken(String token) { + public boolean isRefreshToken(String token) { + return hasTokenType(token, TOKEN_TYPE_REFRESH); + } + + private Claims parseValidClaims(String token) { try { - return UUID.fromString(parseClaims(token).getSubject()); + return parseClaims(token); } catch (ExpiredJwtException e) { - return UUID.fromString(e.getClaims().getSubject()); + throw new TokenExpiredException(); + } catch (JwtException | IllegalArgumentException e) { + throw new InvalidTokenException(); } } - public boolean isAccessToken(String token) { + private boolean hasTokenType(String token, String expectedType) { try { - String type = parseClaims(token).get(CLAIM_TOKEN_TYPE, String.class); - return TOKEN_TYPE_ACCESS.equals(type); - } catch (JwtException e) { + String actualType = parseClaims(token).get(CLAIM_TOKEN_TYPE, String.class); + return expectedType.equals(actualType); + } catch (JwtException | IllegalArgumentException e) { return false; } } + private String generateToken( + UUID memberId, GlobalRole globalRole, String tokenType, long expirationSeconds) { + + Date now = new Date(); + Date expiry = new Date(now.getTime() + expirationSeconds * 1000L); + + return Jwts.builder() + .id(UUID.randomUUID().toString()) + .subject(memberId.toString()) + .claim(CLAIM_ROLE, globalRole.name()) + .claim(CLAIM_TOKEN_TYPE, tokenType) + .issuedAt(now) + .expiration(expiry) + .signWith(secretKey) + .compact(); + } + private Claims parseClaims(String token) { return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload(); } diff --git a/src/main/java/kr/java/documind/global/security/oauth/CustomOAuth2UserService.java b/src/main/java/kr/java/documind/global/security/oauth/CustomOAuth2UserService.java index 10ce113..17b9f83 100644 --- a/src/main/java/kr/java/documind/global/security/oauth/CustomOAuth2UserService.java +++ b/src/main/java/kr/java/documind/global/security/oauth/CustomOAuth2UserService.java @@ -37,6 +37,9 @@ public class CustomOAuth2UserService extends DefaultOAuth2UserService { static final String PENDING_ROLE_COOKIE = "oauth_pending_role"; + static final String EMAIL_CONFLICT_ERROR = "email_conflict"; + static final String ALLOW_EMAIL_DUPLICATE_COOKIE = "oauth_allow_email_duplicate"; + static final String INVITE_FLOW_VALIDATION_ERROR = "invite_flow_role_conflict"; private final MemberService memberService; private final CookieUtil cookieUtil; @@ -70,9 +73,6 @@ public OidcUser loadOidcUser(OidcUserRequest userRequest) throws OAuth2Authentic oidcUser.getUserInfo()); } - static final String EMAIL_CONFLICT_ERROR = "email_conflict"; - static final String ALLOW_EMAIL_DUPLICATE_COOKIE = "oauth_allow_email_duplicate"; - private CustomUserDetails processOAuthLogin( String registrationId, Map attributes, OAuth2UserRequest userRequest) { OAuth2UserProfile userProfile = @@ -81,6 +81,14 @@ private CustomUserDetails processOAuthLogin( String resolvedEmail = resolveEmail(userProfile, userRequest); GlobalRole role = resolveRoleFromCookie(); + if (isInviteFlow() && role == GlobalRole.CEO) { + throw new OAuth2AuthenticationException( + new OAuth2Error( + INVITE_FLOW_VALIDATION_ERROR, + "초대받은 사용자는 관리자(CEO)로 가입할 수 없습니다. 일반 직원으로 다시 시도해주세요.", + null)); + } + String name = Optional.ofNullable(userProfile.getName()).filter(n -> !n.isBlank()).orElse("사용자"); String nickname = @@ -216,6 +224,23 @@ private GlobalRole resolveRoleFromCookie() { } } + private boolean isInviteFlow() { + try { + ServletRequestAttributes attrs = + (ServletRequestAttributes) RequestContextHolder.currentRequestAttributes(); + HttpServletRequest request = attrs.getRequest(); + return cookieUtil + .getCookieValue( + request, + HttpCookieOAuth2AuthorizationRequestRepository + .REDIRECT_AFTER_LOGIN_COOKIE) + .map(path -> path.startsWith("/invite")) + .orElse(false); + } catch (Exception e) { + return false; + } + } + private String buildEmailPlaceholder(OAuth2UserProfile userProfile) { return userProfile.getProvider().name().toLowerCase() + "_" @@ -238,7 +263,9 @@ private boolean isEmailDuplicateAllowed() { } private static String enc(String value) { - if (value == null) return ""; + if (value == null) { + return ""; + } return URLEncoder.encode(value, StandardCharsets.UTF_8); } } diff --git a/src/main/java/kr/java/documind/global/security/oauth/OAuth2SuccessHandler.java b/src/main/java/kr/java/documind/global/security/oauth/OAuth2SuccessHandler.java index 551950e..5c5f632 100644 --- a/src/main/java/kr/java/documind/global/security/oauth/OAuth2SuccessHandler.java +++ b/src/main/java/kr/java/documind/global/security/oauth/OAuth2SuccessHandler.java @@ -3,6 +3,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.util.Optional; import java.util.Set; import kr.java.documind.domain.auth.model.enums.GlobalRole; import kr.java.documind.domain.member.model.entity.Company; @@ -44,7 +45,6 @@ public void onAuthenticationSuccess( throws IOException { CustomUserDetails authMember = (CustomUserDetails) authentication.getPrincipal(); - Member member = memberService.getMemberWithCompany(authMember.getMemberId()); if (!member.isActive()) { @@ -55,6 +55,13 @@ public void onAuthenticationSuccess( return; } + String redirectUrl = determineRedirectUrl(request, member); + + authRequestRepository.removeAuthorizationRequestCookies(request, response); + deletePendingRoleCookie(response); + deleteAllowEmailDuplicateCookie(response); + clearAuthenticationAttributes(request); + String accessToken = jwtProvider.generateAccessToken(member.getId(), member.getGlobalRole()); String refreshToken = @@ -78,60 +85,45 @@ public void onAuthenticationSuccess( member.getId(), refreshToken, jwtProperties.getRefreshExpirationSeconds()); GlobalRole selectedRole = resolveRoleFromCookie(request); - boolean roleMismatch = false; if (selectedRole != null && selectedRole != member.getGlobalRole()) { log.info( "[OAuth2SuccessHandler] 역할 불일치 감지: selected={}, actual={}", selectedRole, member.getGlobalRole()); - roleMismatch = true; + String toastValue = member.isCeo() ? "role_mismatch_ceo" : "role_mismatch_employee"; + redirectUrl += (redirectUrl.contains("?") ? "&" : "?") + "toast_message=" + toastValue; } - authRequestRepository.removeAuthorizationRequestCookies(request, response); - deletePendingRoleCookie(response); - deleteAllowEmailDuplicateCookie(response); - clearAuthenticationAttributes(request); + log.info( + "[OAuth2SuccessHandler] OAuth2 로그인 성공: memberId={} role={} redirect={}", + member.getId(), + member.getGlobalRole(), + redirectUrl); - String redirectAfterLogin = + response.sendRedirect(redirectUrl); + } + + private String determineRedirectUrl(HttpServletRequest request, Member member) { + Optional redirectAfterLogin = cookieUtil .getCookieValue( request, HttpCookieOAuth2AuthorizationRequestRepository .REDIRECT_AFTER_LOGIN_COOKIE) - .filter( - path -> - path.startsWith("/") - && !path.startsWith("//")) // 오픈 리다이렉트 방지 강화 - .orElse(null); - if (redirectAfterLogin != null) { - cookieUtil.deleteCookie( - response, - HttpCookieOAuth2AuthorizationRequestRepository.REDIRECT_AFTER_LOGIN_COOKIE, - jwtProperties.isCookieSecure()); + .filter(path -> path.startsWith("/") && !path.startsWith("//")); + + if (redirectAfterLogin.isPresent()) { log.info( "[OAuth2SuccessHandler] 로그인 후 복귀 리다이렉트: memberId={}, path={}", member.getId(), - redirectAfterLogin); - response.sendRedirect(redirectAfterLogin); - return; + redirectAfterLogin.get()); + return redirectAfterLogin.get(); } - String redirectUrl = resolveRedirectUrl(member); - - if (roleMismatch) { - String toastValue = member.isCeo() ? "role_mismatch_ceo" : "role_mismatch_employee"; - redirectUrl += (redirectUrl.contains("?") ? "&" : "?") + "toast_message=" + toastValue; - } - - log.info( - "[OAuth2SuccessHandler] OAuth2 로그인 성공: memberId={} role={} redirect={}", - member.getId(), - member.getGlobalRole(), - redirectUrl); - response.sendRedirect(redirectUrl); + return resolveDefaultRedirectUrl(member); } - private String resolveRedirectUrl(Member member) { + private String resolveDefaultRedirectUrl(Member member) { String resolved; if (member.isAdmin()) { diff --git a/src/main/resources/static/js/member/ProjectSetting.js b/src/main/resources/static/js/member/ProjectSetting.js index 56160f0..9a86768 100644 --- a/src/main/resources/static/js/member/ProjectSetting.js +++ b/src/main/resources/static/js/member/ProjectSetting.js @@ -359,19 +359,38 @@ async function toggleApiKey(currentStatus) { } -async function changeRole(memberId, newRole) { +async function changeRole(selectElement) { + const memberId = selectElement.dataset.mid; + const newRole = selectElement.value; + const isMe = selectElement.dataset.isMe === 'true'; + + if (isMe && newRole === 'MEMBER') { + const msg = "프로젝트 권한을 '구성원'으로 변경하시겠습니까?\n\n" + + "구성원 권한으로 변경되면 프로젝트 세부사항 변경, API 키 관리,\n" + + "멤버 초대 및 관리 기능을 더 이상 사용할 수 없게 됩니다."; + if (!confirm(msg)) { + selectElement.value = 'MANAGER'; // 취소 시 원래 값으로 복원 + return; + } + } + try { const body = await callApi(`/api/projects/${_PS.publicId}/members/${memberId}/role`, { method: 'PATCH', body: JSON.stringify({ role: newRole }), }); - if (!body.success) { + if (body.success) { + alert('권한이 변경되었습니다.'); + if (isMe) { + location.reload(); // 자신의 권한이 바뀌었으므로 새로고침 + } + } else { alert(body.error?.message ?? '역할 변경에 실패했습니다.'); - window.location.reload(); // 셀렉트 원복 + selectElement.value = newRole === 'MANAGER' ? 'MEMBER' : 'MANAGER'; // API 실패 시 원래 값으로 복원 } } catch (err) { alert(err.message); - window.location.reload(); + selectElement.value = newRole === 'MANAGER' ? 'MEMBER' : 'MANAGER'; // API 실패 시 원래 값으로 복원 } } diff --git a/src/main/resources/templates/auth/login.html b/src/main/resources/templates/auth/login.html index 58e6569..2f649ff 100644 --- a/src/main/resources/templates/auth/login.html +++ b/src/main/resources/templates/auth/login.html @@ -23,10 +23,16 @@

어떤 역할로 시작하시

역할에 따라 접근 권한이 달라집니다

+ +
+ 👋 초대받으신 프로젝트에 합류하려면
일반 직원으로 로그인해주세요. +
+
- -