diff --git a/backend/build.gradle b/backend/build.gradle index 472159ba..8baebbee 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -28,32 +28,47 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // OAuth2 Client implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + + // Test Dependencies + testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testImplementation 'io.projectreactor:reactor-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // Lombok compileOnly 'org.projectlombok:lombok' - runtimeOnly 'org.postgresql:postgresql' annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - + + // PostgreSQL Driver + runtimeOnly 'org.postgresql:postgresql' + + // JWT implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + // OkHttp MockWebServer testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' - implementation 'org.springframework.boot:spring-boot-starter-actuator' // Mockito + AssertJ testImplementation "org.mockito:mockito-junit-jupiter:5.12.0" testImplementation "org.assertj:assertj-core:3.26.3" - // GreenMail: SMTP 가짜 서버 - testImplementation "com.icegreen:greenmail:1.6.14" + // mail + testImplementation "com.icegreen:greenmail:1.6.14" // GreenMail: SMTP 가짜 서버 + implementation 'org.springframework.boot:spring-boot-starter-mail' + + // Spring Boot Admin Server & Client + implementation 'de.codecentric:spring-boot-admin-starter-server:3.5.7' + implementation 'de.codecentric:spring-boot-admin-starter-client:3.5.7' + // Actuator + implementation 'org.springframework.boot:spring-boot-starter-actuator' // spring-retry implementation 'org.springframework.retry:spring-retry' @@ -64,16 +79,12 @@ dependencies { // swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.3' - implementation 'org.springframework.boot:spring-boot-starter-validation' - // backtesting library implementation 'org.ta4j:ta4j-core:0.15' - // mail - implementation 'org.springframework.boot:spring-boot-starter-mail' - //validation implementation 'commons-validator:commons-validator:1.10.0' + implementation 'org.springframework.boot:spring-boot-starter-validation' // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' diff --git a/backend/src/main/java/org/sejongisc/backend/BackendApplication.java b/backend/src/main/java/org/sejongisc/backend/BackendApplication.java index 2e9b11af..878432de 100644 --- a/backend/src/main/java/org/sejongisc/backend/BackendApplication.java +++ b/backend/src/main/java/org/sejongisc/backend/BackendApplication.java @@ -1,5 +1,6 @@ package org.sejongisc.backend; +import de.codecentric.boot.admin.server.config.EnableAdminServer; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @@ -9,6 +10,7 @@ @EnableScheduling @EnableJpaAuditing @EnableRetry +@EnableAdminServer @SpringBootApplication public class BackendApplication { diff --git a/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminAttendanceController.java b/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminAttendanceController.java new file mode 100644 index 00000000..07842fbd --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminAttendanceController.java @@ -0,0 +1,4 @@ +package org.sejongisc.backend.admin.controller; + +public class AdminAttendanceController { +} diff --git a/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminBoardController.java b/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminBoardController.java new file mode 100644 index 00000000..f1a371b7 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminBoardController.java @@ -0,0 +1,4 @@ +package org.sejongisc.backend.admin.controller; + +public class AdminBoardController { +} diff --git a/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminDashboardController.java b/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminDashboardController.java new file mode 100644 index 00000000..50449fd4 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminDashboardController.java @@ -0,0 +1,4 @@ +package org.sejongisc.backend.admin.controller; + +public class AdminDashboardController { +} diff --git a/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminPointController.java b/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminPointController.java new file mode 100644 index 00000000..e7642a27 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminPointController.java @@ -0,0 +1,4 @@ +package org.sejongisc.backend.admin.controller; + +public class AdminPointController { +} diff --git a/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminUserController.java b/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminUserController.java new file mode 100644 index 00000000..2e733a27 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/admin/controller/AdminUserController.java @@ -0,0 +1,78 @@ +package org.sejongisc.backend.admin.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.sejongisc.backend.admin.dto.AdminUserRequest; +import org.sejongisc.backend.user.dto.UserInfoResponse; // 기존 DTO 활용 +import org.sejongisc.backend.user.entity.Role; +import org.sejongisc.backend.user.entity.UserStatus; +import org.sejongisc.backend.user.service.UserService; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/users") +@Tag(name = "관리자 API", description = "운영진 및 개발자용 회원 관리 API") +public class AdminUserController { + + private final UserService userService; + + // --- [회장/운영진용] 회원 관리 API --- + + @Operation(summary = "엑셀 명단 업로드 및 동기화", description = "엑셀 파일을 업로드하여 신규 회원을 등록하고, 기존 회원의 기수/직위를 갱신합니다.") + @PostMapping(value = "/upload-excel", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAnyRole('PRESIDENT', 'SYSTEM_ADMIN')") // 회장, 개발자만 가능 + public ResponseEntity uploadMemberExcel(@RequestPart("file") MultipartFile file) { + // 엑셀 파싱 및 DB 동기화 결과 반환 + //ExcelSyncResultDto result = adminUserService.syncMembersFromExcel(file); + //return ResponseEntity.ok(result); + return null; + } + + @Operation(summary = "전체 회원 목록 조회", description = "모든 회원의 정보를 조회합니다. (회장/관리자용)") + @GetMapping("") + @PreAuthorize("hasAnyRole('SYSTEM_ADMIN', 'MANAGER')") + public ResponseEntity> getAllUsers(@RequestBody AdminUserRequest request) { + //return ResponseEntity.ok(userService.findAllUsers()); // TODO : 전체 조회, 기수별 조회, 이름 검색 등 기능 추가 (페이징은 추후 고려) + return null; + } + + @Operation(summary = "회원 활동 상태 변경", description = "ACTIVE, INACTIVE, GRADUATED 등으로 상태를 변경합니다.") + @PatchMapping("/{userId}/status") + @PreAuthorize("hasAnyRole('SYSTEM_ADMIN', 'MANAGER')") + public ResponseEntity updateUserStatus( + @PathVariable UUID userId, + @RequestParam UserStatus status) { + //userService.updateUserStatus(userId, status); + return ResponseEntity.ok(Map.of("message", "사용자 상태가 " + status + "(으)로 변경되었습니다.")); + } + + // --- [시스템 관리자용 or 회장용] 권한 및 계정 제어 API --- + // TODO : 회장 권한 논의 필요 + @Operation(summary = "회원 권한 변경", description = "특정 유저의 Role(PRESIDENT, VICE_PRESIDENT, TEAM_LEADER)을 변경합니다.)") + @PatchMapping("/{userId}/role") + @PreAuthorize("hasRole('SYSTEM_ADMIN')") + public ResponseEntity updateUserRole( + @PathVariable UUID userId, + @RequestParam Role role) { + //userService.updateUserRole(userId, role); + return ResponseEntity.ok(Map.of("message", "사용자 권한이 " + role + "(으)로 변경되었습니다.")); + } + + @Operation(summary = "회원 강제 탈퇴", description = "시스템에서 유저를 완전히 삭제합니다. (시스템 관리자용)") + @DeleteMapping("/{userId}") + @PreAuthorize("hasRole('SYSTEM_ADMIN')") + public ResponseEntity forceDeleteUser(@PathVariable UUID userId) { + //userService.deleteUserWithOauth(userId); + return ResponseEntity.ok(Map.of("message", "해당 사용자가 시스템에서 완전히 삭제되었습니다.")); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/admin/dto/AdminUserRequest.java b/backend/src/main/java/org/sejongisc/backend/admin/dto/AdminUserRequest.java new file mode 100644 index 00000000..a393dc81 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/admin/dto/AdminUserRequest.java @@ -0,0 +1,5 @@ +package org.sejongisc.backend.admin.dto; + +public class AdminUserRequest { + +} diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceController.java b/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceController.java index 52a37db0..7b5c0ee7 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceController.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceController.java @@ -11,7 +11,7 @@ import org.sejongisc.backend.attendance.dto.AttendanceRoundQrTokenRequest; import org.sejongisc.backend.attendance.dto.AttendanceStatusUpdateRequest; import org.sejongisc.backend.attendance.service.AttendanceService; -import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; +import org.sejongisc.backend.common.auth.dto.CustomUserDetails; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceRoundController.java b/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceRoundController.java index 36d134bf..4389b761 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceRoundController.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceRoundController.java @@ -12,7 +12,7 @@ import org.sejongisc.backend.attendance.dto.AttendanceRoundRequest; import org.sejongisc.backend.attendance.dto.AttendanceRoundResponse; import org.sejongisc.backend.attendance.service.AttendanceRoundService; -import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; +import org.sejongisc.backend.common.auth.dto.CustomUserDetails; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceSessionController.java b/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceSessionController.java index 5aa25f0e..3d8acbc9 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceSessionController.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/controller/AttendanceSessionController.java @@ -11,7 +11,7 @@ import org.sejongisc.backend.attendance.dto.AttendanceSessionRequest; import org.sejongisc.backend.attendance.dto.AttendanceSessionResponse; import org.sejongisc.backend.attendance.service.AttendanceSessionService; -import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; +import org.sejongisc.backend.common.auth.dto.CustomUserDetails; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/controller/SessionUserController.java b/backend/src/main/java/org/sejongisc/backend/attendance/controller/SessionUserController.java index 24a9797b..47a1dfd2 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/controller/SessionUserController.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/controller/SessionUserController.java @@ -10,7 +10,7 @@ import lombok.extern.slf4j.Slf4j; import org.sejongisc.backend.attendance.dto.SessionUserResponse; import org.sejongisc.backend.attendance.service.SessionUserService; -import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; +import org.sejongisc.backend.common.auth.dto.CustomUserDetails; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceService.java b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceService.java index 8953a050..6aa5571b 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceService.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceService.java @@ -15,7 +15,7 @@ import org.sejongisc.backend.attendance.repository.AttendanceRoundRepository; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; -import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.user.repository.UserRepository; import org.sejongisc.backend.user.entity.User; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceSessionService.java b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceSessionService.java index ea66373e..e8ac7bd2 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceSessionService.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/service/AttendanceSessionService.java @@ -12,7 +12,7 @@ import org.sejongisc.backend.attendance.repository.SessionUserRepository; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; -import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.user.repository.UserRepository; import org.sejongisc.backend.user.entity.User; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/service/SessionUserService.java b/backend/src/main/java/org/sejongisc/backend/attendance/service/SessionUserService.java index 8ad8e6fb..a3cc43c4 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/service/SessionUserService.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/service/SessionUserService.java @@ -13,7 +13,7 @@ import org.sejongisc.backend.attendance.repository.SessionUserRepository; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; -import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.user.repository.UserRepository; import org.sejongisc.backend.user.entity.User; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/backend/src/main/java/org/sejongisc/backend/attendance/util/AuthUserUtil.java b/backend/src/main/java/org/sejongisc/backend/attendance/util/AuthUserUtil.java index f7568677..8f4d2cb6 100644 --- a/backend/src/main/java/org/sejongisc/backend/attendance/util/AuthUserUtil.java +++ b/backend/src/main/java/org/sejongisc/backend/attendance/util/AuthUserUtil.java @@ -1,7 +1,8 @@ package org.sejongisc.backend.attendance.util; import java.util.UUID; -import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; + +import org.sejongisc.backend.common.auth.dto.CustomUserDetails; public class AuthUserUtil { private AuthUserUtil() {} diff --git a/backend/src/main/java/org/sejongisc/backend/auth/controller/AuthController.java b/backend/src/main/java/org/sejongisc/backend/auth/controller/AuthController.java deleted file mode 100644 index 2b03db9d..00000000 --- a/backend/src/main/java/org/sejongisc/backend/auth/controller/AuthController.java +++ /dev/null @@ -1,362 +0,0 @@ -package org.sejongisc.backend.auth.controller; - -import io.jsonwebtoken.JwtException; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.sejongisc.backend.auth.dto.*; -import org.sejongisc.backend.auth.repository.RefreshTokenRepository; -import org.sejongisc.backend.auth.service.*; -import org.sejongisc.backend.common.auth.jwt.JwtProvider; -import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; -import org.sejongisc.backend.common.exception.CustomException; -import org.sejongisc.backend.user.entity.User; -import org.sejongisc.backend.auth.oauth.GithubUserInfoAdapter; -import org.sejongisc.backend.auth.oauth.GoogleUserInfoAdapter; -import org.sejongisc.backend.auth.oauth.KakaoUserInfoAdapter; -import org.sejongisc.backend.user.service.UserService; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseCookie; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; - -import java.io.IOException; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.Map; - -@Slf4j -@RestController -@RequestMapping("/api/auth") -@RequiredArgsConstructor -@Tag( - name = "인증 API", - description = "회원 인증 및 소셜 로그인 관련 API를 제공합니다." -) -public class AuthController { - - private final LoginService loginService; - private final UserService userService; - private final JwtProvider jwtProvider; - private final RefreshTokenService refreshTokenService; - - - @Operation( - summary = "회원가입 API", - description = """ - 회원 이메일, 비밀번호, 이름, 전화번호 정보를 입력받아 새로운 사용자를 생성합니다. - - 비밀번호 정책: - - 길이: 8~20자 - - 최소 1개의 대문자(A-Z) - - 최소 1개의 소문자(a-z) - - 최소 1개의 숫자(0-9) - - 최소 1개의 특수문자(!@#$%^&*()_+=-{};:'",.<>/?) - - 위 조건을 모두 만족하지 않으면 400 (INVALID_INPUT) 예외가 발생합니다. - """, - - responses = { - @ApiResponse( - responseCode = "201", - description = "회원가입 성공", - content = @Content(mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "userId": "1c54b9f3-8234-4e8f-b001-11cc4d9012ab", - "email": "testuser@example.com", - "name": "홍길동", - "phoneNumber": "01012345678", - "role": "TEAM_MEMBER" - } - """)) - ), - @ApiResponse( - responseCode = "400", - description = "요청 데이터 유효성 검증 실패 (비밀번호 정책 미준수 포함)", - content = @Content(mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "message": "비밀번호는 8~20자, 대소문자/숫자/특수문자를 모두 포함해야 합니다." - } - """)) - ) - } - ) - @PostMapping("/signup") - public ResponseEntity signup(@Valid @RequestBody SignupRequest request) { - log.info("[SIGNUP] request: {}", request.getEmail()); - SignupResponse response = userService.signUp(request); - return ResponseEntity.status(HttpStatus.CREATED).body(response); - } - - @Operation( - summary = "일반 로그인 API", - description = "이메일과 비밀번호로 로그인하고 Access Token과 Refresh Token을 발급합니다.", - responses = { - @ApiResponse( - responseCode = "200", - description = "로그인 성공", - content = @Content(mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "accessToken": "eyJhbGciOiJIUzI1NiJ9...", - "refreshToken": "eyJhbGciOiJIUzI1NiJ9...", - "userId": "1c54b9f3-8234-4e8f-b001-11cc4d9012ab", - "name": "홍길동", - "role": "TEAM_MEMBER", - "phoneNumber": "01012345678" - } - """)) - ), - @ApiResponse(responseCode = "401", description = "이메일 또는 비밀번호 불일치") - } - ) - @PostMapping("/login") - public ResponseEntity login(@Valid @RequestBody LoginRequest request) { - - LoginResponse response = loginService.login(request); - - // accessToken을 HttpOnly 쿠키로 설정 - ResponseCookie accessCookie = ResponseCookie.from("access", response.getAccessToken()) - .httpOnly(true) - .secure(true) - .sameSite("None") - .path("/") - .maxAge(60L * 60) // 1 hour - .build(); - - // refreshToken을 HttpOnly 쿠키로 설정 - ResponseCookie refreshCookie = ResponseCookie.from("refresh", response.getRefreshToken()) - .httpOnly(true) - .secure(true) - .sameSite("None") - .path("/") - .maxAge(60L * 60 * 24 * 14) // 2 weeks - .build(); - - // JSON 응답에서 토큰 제거, 유저 정보만 포함 - LoginResponse safeResponse = LoginResponse.builder() - .userId(response.getUserId()) - .email(response.getEmail()) - .name(response.getName()) - .role(response.getRole()) - .phoneNumber(response.getPhoneNumber()) - .point(response.getPoint()) - .build(); - - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, accessCookie.toString()) - .header(HttpHeaders.SET_COOKIE, refreshCookie.toString()) - .body(safeResponse); - } - - @Operation( - summary = "Access Token 재발급 API", - description = "만료된 Access Token을 Refresh Token으로 재발급받습니다.", - responses = { - @ApiResponse( - responseCode = "200", - description = "Access Token 재발급 성공", - content = @Content(mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "accessToken": "eyJhbGciOiJIUzI1NiJ9..." - } - """)) - ), - @ApiResponse( - responseCode = "401", - description = "Refresh Token이 없거나 만료됨", - content = @Content(mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "message": "Refresh Token이 유효하지 않거나 만료되었습니다." - } - """)) - ) - } - ) - @PostMapping("/reissue") - public ResponseEntity reissue( - @Parameter(description = "Refresh Token 쿠키", example = "refresh=abc123") - @CookieValue(value = "refresh", required = false) String refreshToken - ) { - - // 쿠키에 refreshToken이 없으면 401 - if (refreshToken == null || refreshToken.isEmpty()) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("message", "Refresh Token이 없습니다.")); - } - - try { - // 서비스 호출 → accessToken / refreshToken 갱신 - Map tokens = refreshTokenService.reissueTokens(refreshToken); - - // accessToken을 Authorization 헤더로 전달 - ResponseEntity.BodyBuilder response = ResponseEntity.ok() - .header(HttpHeaders.AUTHORIZATION, "Bearer " + tokens.get("accessToken")); - - // refreshToken이 새로 발급된 경우 쿠키 교체 - if (tokens.containsKey("refreshToken")) { - ResponseCookie cookie = ResponseCookie.from("refresh", tokens.get("refreshToken")) - .httpOnly(true) - .secure(true) // Swagger/Postman 테스트 중일 땐 false - .sameSite("None") - .path("/") - .maxAge(60L * 60 * 24 * 14) // 2주 - .build(); - - response.header(HttpHeaders.SET_COOKIE, cookie.toString()); - } - - // accessToken을 HttpOnly 쿠키로 설정 - ResponseCookie accessCookie = ResponseCookie.from("access", tokens.get("accessToken")) - .httpOnly(true) - .secure(true) - .sameSite("None") - .path("/") - .maxAge(60L * 60) // 1 hour - .build(); - - response.header(HttpHeaders.SET_COOKIE, accessCookie.toString()); - - // JSON에서 accessToken 제거 - return response.body(Map.of("message", "토큰 갱신 성공")); - - } catch (Exception e) { - log.warn("토큰 재발급 실패: {}", e.getMessage()); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("message", "Refresh Token이 유효하지 않거나 만료되었습니다.")); - } - } - - @Operation( - summary = "로그아웃 API", - description = "Access Token을 무효화하고 Access/Refresh Token 쿠키를 삭제합니다. 토큰이 없어도 정상적으로 처리됩니다.", - responses = { - @ApiResponse( - responseCode = "200", - description = "로그아웃 성공", - content = @Content(mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "message": "로그아웃 성공" - } - """)) - ) - } - ) - @PostMapping("/logout") - public ResponseEntity logout( - @Parameter(description = "Access Token 쿠키", example = "access=abc123") - @CookieValue(value = "access", required = false) String accessToken, - @Parameter(description = "Refresh Token 쿠키", example = "refresh=abc123") - @CookieValue(value = "refresh", required = false) String refreshToken - ) { - // 토큰이 없어도 로그아웃 처리 (멱등성 보장) - if (accessToken != null && !accessToken.isEmpty()) { - try { - loginService.logout(accessToken); - } catch (JwtException | IllegalArgumentException e) { - log.warn("Invalid or expired JWT during logout: {}", e.getMessage()); - } catch (Exception e) { - log.error("Unexpected error during logout", e); - } - } - - // Access Token 쿠키 삭제 - ResponseCookie deleteAccessCookie = ResponseCookie.from("access", "") - .httpOnly(true) - .secure(true) - .sameSite("None") - .path("/") - .maxAge(0) - .build(); - - // Refresh Token 쿠키 삭제 - ResponseCookie deleteRefreshCookie = ResponseCookie.from("refresh", "") - .httpOnly(true) - .secure(true) - .sameSite("None") - .path("/") - .maxAge(0) - .build(); - - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, deleteAccessCookie.toString()) - .header(HttpHeaders.SET_COOKIE, deleteRefreshCookie.toString()) - .body(Map.of("message", "로그아웃 성공")); - } - - @Operation( - summary = "회원 탈퇴 API", - description = "현재 로그인한 사용자의 계정을 삭제합니다.", - responses = { - @ApiResponse( - responseCode = "200", - description = "회원 탈퇴 완료", - content = @Content(mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "message": "회원 탈퇴가 완료되었습니다." - } - """)) - ), - @ApiResponse( - responseCode = "401", - description = "인증되지 않은 사용자", - content = @Content(mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "message": "인증이 필요합니다." - } - """)) - ) - } - ) - @DeleteMapping("/withdraw") - public ResponseEntity withdraw( - @Parameter(hidden = true) - @AuthenticationPrincipal CustomUserDetails user - ) { - if (user == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("message", "인증이 필요합니다.")); - } - - // DB에서 사용자 정보 삭제 - userService.deleteUserWithOauth(user.getUserId()); - log.info("회원 탈퇴 완료: {}", user.getEmail()); - - //Refresh Token DB에서도 삭제 - refreshTokenService.deleteByUserId(user.getUserId()); - - // 브라우저 쿠키 삭제 - ResponseCookie deleteCookie = ResponseCookie.from("refresh", "") - .httpOnly(true) - .secure(true) - .sameSite("None") - .path("/") - .maxAge(0) - .build(); - - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, deleteCookie.toString()) // 나중에 추가 - .body(Map.of("message", "회원 탈퇴가 완료되었습니다.")); - } - -} - diff --git a/backend/src/main/java/org/sejongisc/backend/auth/dto/SignupRequest.java b/backend/src/main/java/org/sejongisc/backend/auth/dto/SignupRequest.java deleted file mode 100644 index 6b443ab7..00000000 --- a/backend/src/main/java/org/sejongisc/backend/auth/dto/SignupRequest.java +++ /dev/null @@ -1,68 +0,0 @@ -package org.sejongisc.backend.auth.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Pattern; -import lombok.*; -import org.sejongisc.backend.user.entity.Role; - -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -@Schema( - name = "SignupRequest", - description = "회원가입 요청 객체 (이름, 이메일, 비밀번호, 역할, 전화번호 입력)" -) -public class SignupRequest { - - @Schema( - description = "사용자 이름", - example = "홍길동", - requiredMode = Schema.RequiredMode.REQUIRED - ) - @NotBlank(message = "이름은 필수입니다.") - private String name; - - @Schema( - description = "사용자 이메일 주소 (유효한 이메일 형식이어야 함)", - example = "testuser@example.com", - requiredMode = Schema.RequiredMode.REQUIRED - ) - @NotBlank(message = "이메일은 필수입니다.") - @Pattern( - regexp = "^[A-Za-z0-9][A-Za-z0-9+_.'-]*[A-Za-z0-9]@[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*\\.[A-Za-z]{2,}$", - message = "유효한 이메일 형식이 아닙니다." - ) - private String email; - - @Schema( - description = "사용자 비밀번호 (8자 이상, 숫자/영문/특수문자 조합 권장)", - example = "Abcd1234!", - requiredMode = Schema.RequiredMode.REQUIRED - ) - @NotBlank(message = "비밀번호는 필수입니다.") - private String password; - - @Schema( - description = "사용자 역할 (USER 또는 ADMIN 등)", - example = "TEAM_MEMBER", - requiredMode = Schema.RequiredMode.REQUIRED - ) - @NotNull(message = "역할은 필수입니다.") - private Role role; - - @Schema( - description = "전화번호 (숫자만 입력, 10~11자리)", - example = "01012345678", - requiredMode = Schema.RequiredMode.REQUIRED - ) - @NotBlank(message = "전화번호는 필수입니다.") - @Pattern( - regexp = "^[0-9]{10,11}$", - message = "전화번호는 10~11자리 숫자여야 합니다." - ) - private String phoneNumber; -} diff --git a/backend/src/main/java/org/sejongisc/backend/auth/service/GithubServiceImpl.java b/backend/src/main/java/org/sejongisc/backend/auth/service/GithubServiceImpl.java deleted file mode 100644 index 8e5ccfc1..00000000 --- a/backend/src/main/java/org/sejongisc/backend/auth/service/GithubServiceImpl.java +++ /dev/null @@ -1,104 +0,0 @@ -package org.sejongisc.backend.auth.service; - -import lombok.extern.slf4j.Slf4j; -import org.sejongisc.backend.auth.dto.GithubTokenResponse; -import org.sejongisc.backend.auth.dto.GithubUserInfoResponse; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatusCode; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Service; -import org.springframework.web.reactive.function.BodyInserter; -import org.springframework.web.reactive.function.BodyInserters; -import org.springframework.web.reactive.function.client.WebClient; -import reactor.core.publisher.Mono; - -import java.util.function.Function; - -@Slf4j -@Service("GITHUB") -public class GithubServiceImpl implements Oauth2Service { - - private final String clientId; - private final String clientSecret; - - private final String TOKEN_URL; - private final String USERINFO_URL; - - @Autowired - public GithubServiceImpl( - @Value("${github.client.id}") String clientId, - @Value("${github.client.secret}") String clientSecret) { - this.clientId = clientId; - this.clientSecret = clientSecret; - this.TOKEN_URL = "https://github.com/login/oauth/access_token"; - this.USERINFO_URL = "https://api.github.com/user"; - } - - // ✅ 테스트용 생성자 - public GithubServiceImpl(String clientId, String clientSecret, - String tokenUrl, String userInfoUrl) { - this.clientId = clientId; - this.clientSecret = clientSecret; - this.TOKEN_URL = tokenUrl; - this.USERINFO_URL = userInfoUrl; - } - - @Override - public GithubTokenResponse getAccessToken(String code) { - GithubTokenResponse tokenResponse = WebClient.create(TOKEN_URL).post() - .uri(uriBuilder -> uriBuilder.build(true)) - .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) - .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) - .body(BodyInserters.fromFormData("client_id", clientId) - .with("client_secret", clientSecret) - .with("code", code)) - .retrieve() - .onStatus(HttpStatusCode::is4xxClientError, - clientResponse -> Mono.error(new RuntimeException("Invalid Parameter"))) - .onStatus(HttpStatusCode::is5xxServerError, - clientResponse -> Mono.error(new RuntimeException("Internal Server Error"))) - .bodyToMono(GithubTokenResponse.class) - .block(); - - if (tokenResponse == null || tokenResponse.getAccessToken() == null) { - throw new RuntimeException("Token response is empty"); - } - - Function mask = token -> { - if(token == null || token.length() < 8) return "****"; - return token.substring(0, 4) + "..." + token.substring(token.length() - 4); - }; - - log.debug(" [Github Service] Access Token ------> {}", mask.apply(tokenResponse.getAccessToken())); - log.debug(" [Github Service] Scope ------> {}", mask.apply(tokenResponse.getScope())); - - return tokenResponse; - } - - @Override - public GithubUserInfoResponse getUserInfo(String accessToken) { - GithubUserInfoResponse userInfo = WebClient.create(USERINFO_URL).get() - .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) - .retrieve() - .onStatus(HttpStatusCode::is4xxClientError, - clientResponse -> Mono.error(new RuntimeException("Invalid Parameter"))) - .onStatus(HttpStatusCode::is5xxServerError, - clientResponse -> Mono.error(new RuntimeException("Internal Server Error"))) - .bodyToMono(GithubUserInfoResponse.class) - .block(); - - if (userInfo == null) { - throw new RuntimeException("UserInfo response is empty"); - } - - if (log.isDebugEnabled()) { - log.debug(" [Github Service] ID ------> {}", userInfo.getId()); - log.debug(" [Github Service] Login ------> {}", userInfo.getLogin()); - log.debug(" [Github Service] Name ------> {}", userInfo.getName()); - } - - return userInfo; - } -} diff --git a/backend/src/main/java/org/sejongisc/backend/auth/service/LoginService.java b/backend/src/main/java/org/sejongisc/backend/auth/service/LoginService.java deleted file mode 100644 index 3f6b10cc..00000000 --- a/backend/src/main/java/org/sejongisc/backend/auth/service/LoginService.java +++ /dev/null @@ -1,11 +0,0 @@ -package org.sejongisc.backend.auth.service; - -import jakarta.servlet.http.HttpServletRequest; -import org.sejongisc.backend.auth.dto.LoginRequest; -import org.sejongisc.backend.auth.dto.LoginResponse; - -public interface LoginService { - LoginResponse login(LoginRequest request); - - void logout(String accessToken); -} diff --git a/backend/src/main/java/org/sejongisc/backend/auth/service/RefreshTokenService.java b/backend/src/main/java/org/sejongisc/backend/auth/service/RefreshTokenService.java deleted file mode 100644 index e61d4886..00000000 --- a/backend/src/main/java/org/sejongisc/backend/auth/service/RefreshTokenService.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.sejongisc.backend.auth.service; - -import java.util.Map; -import java.util.UUID; - -public interface RefreshTokenService { - - /** - * Refresh Token을 검증하고 새로운 Access Token을 재발급합니다. - * Refresh Token의 만료가 임박하면 새 Refresh Token도 함께 반환합니다. - * - * @param refreshToken 클라이언트의 Refresh Token - * @return Map { - * "accessToken": 새 Access Token, - * "refreshToken": (선택적) 새 Refresh Token - * } - */ - Map reissueTokens(String refreshToken); - void deleteByUserId(UUID userId); - void saveOrUpdateToken(UUID userId, String refreshToken); -} diff --git a/backend/src/main/java/org/sejongisc/backend/backtest/controller/BacktestController.java b/backend/src/main/java/org/sejongisc/backend/backtest/controller/BacktestController.java index 311f64b3..efd1f682 100644 --- a/backend/src/main/java/org/sejongisc/backend/backtest/controller/BacktestController.java +++ b/backend/src/main/java/org/sejongisc/backend/backtest/controller/BacktestController.java @@ -10,9 +10,8 @@ import org.sejongisc.backend.backtest.dto.BacktestRequest; import org.sejongisc.backend.backtest.dto.BacktestResponse; -import org.sejongisc.backend.backtest.dto.BacktestRunRequest; import org.sejongisc.backend.backtest.service.BacktestService; -import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; +import org.sejongisc.backend.common.auth.dto.CustomUserDetails; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; diff --git a/backend/src/main/java/org/sejongisc/backend/backtest/service/BacktestService.java b/backend/src/main/java/org/sejongisc/backend/backtest/service/BacktestService.java index 968b2d61..2994a92e 100644 --- a/backend/src/main/java/org/sejongisc/backend/backtest/service/BacktestService.java +++ b/backend/src/main/java/org/sejongisc/backend/backtest/service/BacktestService.java @@ -1,7 +1,6 @@ package org.sejongisc.backend.backtest.service; import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.sejongisc.backend.backtest.dto.BacktestRequest; @@ -17,7 +16,7 @@ import org.sejongisc.backend.stock.repository.PriceDataRepository; import org.sejongisc.backend.template.entity.Template; import org.sejongisc.backend.template.repository.TemplateRepository; -import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.user.repository.UserRepository; import org.sejongisc.backend.user.entity.User; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.java b/backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.java index 17de4597..1d62b12d 100644 --- a/backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.java +++ b/backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.java @@ -5,15 +5,13 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; -import jakarta.validation.constraints.Pattern; import lombok.RequiredArgsConstructor; import org.sejongisc.backend.betting.dto.BetRoundResponse; import org.sejongisc.backend.betting.dto.UserBetRequest; import org.sejongisc.backend.betting.entity.BetRound; import org.sejongisc.backend.betting.entity.Scope; -import org.sejongisc.backend.betting.entity.UserBet; import org.sejongisc.backend.betting.service.BettingService; -import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; +import org.sejongisc.backend.common.auth.dto.CustomUserDetails; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; diff --git a/backend/src/main/java/org/sejongisc/backend/board/controller/BoardController.java b/backend/src/main/java/org/sejongisc/backend/board/controller/BoardController.java index fbb77cc1..98e1dc95 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/controller/BoardController.java +++ b/backend/src/main/java/org/sejongisc/backend/board/controller/BoardController.java @@ -13,7 +13,7 @@ import org.sejongisc.backend.board.dto.PostResponse; import org.sejongisc.backend.board.service.PostInteractionService; import org.sejongisc.backend.board.service.PostService; -import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; +import org.sejongisc.backend.common.auth.dto.CustomUserDetails; import org.springframework.data.domain.Page; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; diff --git a/backend/src/main/java/org/sejongisc/backend/board/service/PostInteractionService.java b/backend/src/main/java/org/sejongisc/backend/board/service/PostInteractionService.java index 4bd0862b..5e264b07 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/service/PostInteractionService.java +++ b/backend/src/main/java/org/sejongisc/backend/board/service/PostInteractionService.java @@ -16,7 +16,7 @@ import org.sejongisc.backend.board.repository.PostRepository; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; -import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.user.repository.UserRepository; import org.sejongisc.backend.user.entity.Role; import org.sejongisc.backend.user.entity.User; import org.springframework.orm.ObjectOptimisticLockingFailureException; diff --git a/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java b/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java index 1e90384c..382d8f91 100644 --- a/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java +++ b/backend/src/main/java/org/sejongisc/backend/board/service/PostServiceImpl.java @@ -23,7 +23,7 @@ import org.sejongisc.backend.board.repository.PostRepository; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; -import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.user.repository.UserRepository; import org.sejongisc.backend.user.dto.UserInfoResponse; import org.sejongisc.backend.user.entity.Role; import org.sejongisc.backend.user.entity.User; diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/config/CustomOAuth2UserService.java b/backend/src/main/java/org/sejongisc/backend/common/auth/config/CustomOAuth2UserService.java deleted file mode 100644 index 3ad7350c..00000000 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/config/CustomOAuth2UserService.java +++ /dev/null @@ -1,118 +0,0 @@ -package org.sejongisc.backend.common.auth.config; - -import jakarta.transaction.Transactional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.sejongisc.backend.auth.dao.UserOauthAccountRepository; -import org.sejongisc.backend.auth.entity.AuthProvider; -import org.sejongisc.backend.auth.entity.UserOauthAccount; -import org.sejongisc.backend.user.dao.UserRepository; -import org.sejongisc.backend.user.entity.Role; -import org.sejongisc.backend.user.entity.User; -import org.sejongisc.backend.user.service.UserServiceImpl; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.user.DefaultOAuth2User; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Map; - -@Slf4j -@Service -@RequiredArgsConstructor -@Transactional -public class CustomOAuth2UserService implements OAuth2UserService { - private final UserRepository userRepository; - private final UserOauthAccountRepository oauthAccountRepository; - - @Override - public OAuth2User loadUser(OAuth2UserRequest req) throws OAuth2AuthenticationException { - // log.info("[CustomOAuth2UserService] loadUser START"); - - OAuth2UserService delegate = - new DefaultOAuth2UserService(); - OAuth2User oAuth2User = delegate.loadUser(req); - - String provider = req.getClientRegistration().getRegistrationId(); // google, kakao, github - Map attrs = oAuth2User.getAttributes(); - - String providerUid; - String email; - String name; - - // log.info("[OAuth2] Provider = {}", provider); - if (log.isDebugEnabled()) { - log.debug("[OAuth2] Attributes = {}", attrs); - } - - switch (provider) { - case "google" -> { - providerUid = (String) attrs.get("sub"); - email = (String) attrs.get("email"); - name = (String) attrs.get("name"); - } - case "kakao" -> { - providerUid = attrs.get("id").toString(); - Map kakaoAccount = (Map) attrs.get("kakao_account"); - email = (String) kakaoAccount.get("email"); // null 가능 - Map profile = (Map) kakaoAccount.get("profile"); - name = (String) profile.get("nickname"); - } - case "github" -> { - providerUid = attrs.get("id").toString(); - email = (String) attrs.get("email"); - name = (String) attrs.get("login"); // GitHub은 login이 닉네임 - } - default -> throw new RuntimeException("지원하지 않는 provider: " + provider); - } - - // log.info("provider={}, providerUid={}, email={}, name={}", provider, providerUid, email, name); - - final String fProviderUid = providerUid; - final String fEmail = email; - final String fName = name; - final AuthProvider fAuthProvider = AuthProvider.valueOf(provider.toUpperCase()); - - User user = oauthAccountRepository - .findByProviderAndProviderUid(AuthProvider.from(provider), providerUid) - .map(UserOauthAccount::getUser) - .orElseGet(() -> { - User newUser = User.builder() - .email(email) - .name(name) - .role(Role.TEAM_MEMBER) - .build(); - User saved = userRepository.save(newUser); - - UserOauthAccount oauth = UserOauthAccount.builder() - .user(saved) - .provider(AuthProvider.from(provider)) - .providerUid(providerUid) - .build(); - oauthAccountRepository.save(oauth); - - return saved; - }); - - // log.info("[CustomOAuth2UserService] User resolved → returning OAuth2User"); - - Map attributes = new java.util.HashMap<>(); - attributes.put("provider", provider); // google / kakao / github - attributes.put("providerUid", providerUid); // 소셜 계정 UID - attributes.put("email", user.getEmail()); // DB email - attributes.put("name", user.getName()); - attributes.put("userId", user.getUserId()); // DB user uuid - - return new DefaultOAuth2User( - List.of(new SimpleGrantedAuthority("ROLE_TEAM_MEMBER")), - attributes, - "userId" // 또는 "email" -> email null 이면 id가 더 안전 - ); - - } -} diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/config/CustomOidcUserService.java b/backend/src/main/java/org/sejongisc/backend/common/auth/config/CustomOidcUserService.java deleted file mode 100644 index 46167f55..00000000 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/config/CustomOidcUserService.java +++ /dev/null @@ -1,93 +0,0 @@ -package org.sejongisc.backend.common.auth.config; - -import jakarta.transaction.Transactional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.sejongisc.backend.auth.dao.UserOauthAccountRepository; -import org.sejongisc.backend.auth.entity.AuthProvider; -import org.sejongisc.backend.auth.entity.UserOauthAccount; -import org.sejongisc.backend.user.dao.UserRepository; -import org.sejongisc.backend.user.entity.Role; -import org.sejongisc.backend.user.entity.User; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; -import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; -import org.springframework.stereotype.Service; - -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - -@Slf4j -@Service -@RequiredArgsConstructor -@Transactional -public class CustomOidcUserService extends OidcUserService { - - private final UserRepository userRepository; - private final UserOauthAccountRepository oauthAccountRepository; - - @Override - public OidcUser loadUser(OidcUserRequest req) throws OAuth2AuthenticationException { - // log.info("[CustomOidcUserService] Google OIDC loadUser START"); - - OidcUser oidcUser = super.loadUser(req); - - Map original = oidcUser.getAttributes(); - // log.info("OIDC claims: {}", original); - - // SuccessHandler가 필요로 하는 attributes 넣기 - String provider = "google"; // provider - String providerUid = (String) original.get("sub"); // 구글 고유 ID - String email = (String) original.get("email"); - String name = (String) original.get("name"); - - // 신규 가입 or 기존 유저 조회 - User user = oauthAccountRepository - .findByProviderAndProviderUid(AuthProvider.GOOGLE, providerUid) - .map(UserOauthAccount::getUser) - .orElseGet(() -> { - // (1) User 생성 - User newUser = User.builder() - .email(email) - .name(name) - .role(Role.TEAM_MEMBER) - .build(); - User savedUser = userRepository.save(newUser); - - // (2) UserOauthAccount 생성 - UserOauthAccount oauth = UserOauthAccount.builder() - .user(savedUser) - .provider(AuthProvider.GOOGLE) - .providerUid(providerUid) - .build(); - oauthAccountRepository.save(oauth); - - // log.info("[CustomOidcUserService] 신규 User 및 UserOauthAccount 생성됨"); - return savedUser; - }); - - Map attrs = new HashMap<>(original); - attrs.put("provider", provider); - attrs.put("providerUid", providerUid); - attrs.put("email", user.getEmail()); - attrs.put("name", user.getName()); - attrs.put("userId", user.getUserId()); // SuccessHandler가 필요로 함 - - // 여기서 attrs를 defaultOidcUser에 직접 넣음 - return new DefaultOidcUser( - oidcUser.getAuthorities(), - oidcUser.getIdToken(), - oidcUser.getUserInfo(), - "sub" - ) { - @Override - public Map getAttributes() { - return attrs; // 커스텀 attributes 적용 - } - }; - } -} diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/config/OAuth2SuccessHandler.java b/backend/src/main/java/org/sejongisc/backend/common/auth/config/OAuth2SuccessHandler.java deleted file mode 100644 index baa3c490..00000000 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/config/OAuth2SuccessHandler.java +++ /dev/null @@ -1,172 +0,0 @@ -package org.sejongisc.backend.common.auth.config; - -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.core.env.Environment; -import org.sejongisc.backend.auth.dao.UserOauthAccountRepository; -import org.sejongisc.backend.auth.entity.AuthProvider; -import org.sejongisc.backend.auth.entity.UserOauthAccount; -import org.sejongisc.backend.common.auth.jwt.JwtProvider; -import org.sejongisc.backend.auth.service.RefreshTokenService; -import org.sejongisc.backend.user.dao.UserRepository; -import org.sejongisc.backend.user.entity.User; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseCookie; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.oauth2.core.oidc.user.OidcUser; -import org.springframework.security.oauth2.core.user.DefaultOAuth2User; -import org.springframework.security.web.authentication.AuthenticationSuccessHandler; -import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; -import org.springframework.stereotype.Component; - -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -@Slf4j -@Component -@RequiredArgsConstructor -public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { - - private final JwtProvider jwtProvider; - private final RefreshTokenService refreshTokenService; - private final UserRepository userRepository; - private final UserOauthAccountRepository userOauthAccountRepository; - private final Environment env; - - @Value("${app.oauth2.redirect-success}") - private String redirectSuccessBase; - - @Override - public void onAuthenticationSuccess( - HttpServletRequest request, - HttpServletResponse response, - Authentication authentication) throws IOException{ - - // log.info("[OAuth2SuccessHandler] SUCCESS HANDLER CALLED!"); - - if (!(authentication.getPrincipal() instanceof DefaultOAuth2User oauthUser)) { - throw new IllegalStateException("Unknown principal type: " + authentication.getPrincipal().getClass()); - } - - // 1. CustomOAuth2UserService에서 넣어준 attributes 가져오기 - Map attrs = oauthUser.getAttributes(); - - String providerStr = (String) attrs.get("provider"); - String providerUid = (String) attrs.get("providerUid"); - if (providerStr == null) { - throw new IllegalStateException("OAuth provider attribute missing from attributes"); - } - - AuthProvider provider = - switch (providerStr) { - case "kakao" -> AuthProvider.KAKAO; - case "github" -> AuthProvider.GITHUB; - case "google" -> AuthProvider.GOOGLE; - default -> throw new IllegalStateException("Unknown OAuth provider: " + providerStr); - }; - - - // log.info("[OAuth2SuccessHandler] provider={}, providerUid={}", provider, providerUid); - - // DB 조회 - UserOauthAccount account = userOauthAccountRepository - .findByProviderAndProviderUid(provider, providerUid) - .orElseThrow(() -> new RuntimeException("소셜 계정이 DB에 없습니다. (회원가입 필요)")); - - User user = userRepository.findById(account.getUser().getUserId()) - .orElseThrow(() -> new RuntimeException("User not found")); - - // JWT 생성 - String accessToken = jwtProvider.createToken( - user.getUserId(), - user.getRole(), - user.getEmail() - ); - - - // 4. RefreshToken 생성 - String refreshToken = jwtProvider.createRefreshToken(user.getUserId()); - // 5. RefreshToken 저장(DB or Redis) - refreshTokenService.saveOrUpdateToken(user.getUserId(), refreshToken); - - String[] activeProfiles = env.getActiveProfiles(); - List profiles = Arrays.asList(activeProfiles); - - boolean isProd = profiles.contains("prod"); - boolean isDev = profiles.contains("dev"); - -// SameSite, Secure 설정 (dev도 prod와 동일하게) - String sameSite = (isProd || isDev) ? "None" : "Lax"; - boolean secure = (isProd || isDev); - -// 도메인 설정 - String domain; - if (isProd) { - domain = "sjusisc.com"; // 운영 도메인 - } else if (isDev) { - domain = "sisc-web.duckdns.org"; // 개발 도메인 - } else { - domain = "localhost"; // 기본값 - } - - - - - // 6. HttpOnly 쿠키로 refreshToken 저장 - ResponseCookie.ResponseCookieBuilder accessCookieBuilder = ResponseCookie.from("access", accessToken) - .httpOnly(true) - .secure(secure) // 로컬=false, 배포=true - .sameSite(sameSite) // 로컬= "Lax", 배포="None" - .path("/") - .maxAge(60L * 60); // 1 hour - - // 로컬 환경에서는 domain 설정하지 않음 - if (isProd || isDev) { - accessCookieBuilder.domain(domain); - } - - ResponseCookie.ResponseCookieBuilder refreshCookieBuilder = ResponseCookie.from("refresh", refreshToken) - .httpOnly(true) - .secure(secure) - .sameSite(sameSite) - .path("/") - .maxAge(60L * 60 * 24 * 14); // 2 weeks - - // 로컬 환경에서는 domain 설정하지 않음 - if (isProd || isDev) { - refreshCookieBuilder.domain(domain); - } - - ResponseCookie accessCookie = accessCookieBuilder.build(); - ResponseCookie refreshCookie = refreshCookieBuilder.build(); - - - response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString()); - response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString()); - - - // 7. 프론트로 redirect - // application-local.yml → http://localhost:5173/oauth/success - // application-prod.yml → https://sisc-web.duckdns.org/oauth/success - //String redirectUrl = redirectSuccessBase; -// + "?accessToken=" + accessToken -// + "&name=" + URLEncoder.encode(name, StandardCharsets.UTF_8) -// + "&userId=" + userId; - - // log.info("[OAuth2 Redirect] {}", redirectUrl); - - getRedirectStrategy().sendRedirect(request, response, redirectSuccessBase); - } - -} diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/controller/AuthController.java b/backend/src/main/java/org/sejongisc/backend/common/auth/controller/AuthController.java new file mode 100644 index 00000000..c8773cf3 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/controller/AuthController.java @@ -0,0 +1,76 @@ +package org.sejongisc.backend.common.auth.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sejongisc.backend.common.auth.dto.AuthRequest; +import org.sejongisc.backend.common.auth.dto.AuthResponse; +import org.sejongisc.backend.common.auth.service.AuthService; +import org.sejongisc.backend.common.auth.service.RefreshTokenService; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@Slf4j +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +@Tag(name = "인증 API", description = "회원 인증 및 소셜 로그인 관련 API를 제공합니다.") +public class AuthController { + + private final AuthService authService; + private final RefreshTokenService refreshTokenService; + private final AuthCookieHelper cookieHelper; // 주입 + + @Operation(summary = "일반 로그인 API", description = "") + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody AuthRequest request) { + AuthResponse response = authService.login(request); + + ResponseCookie accessCookie = cookieHelper.createAccessCookie(response.getAccessToken()); + ResponseCookie refreshCookie = cookieHelper.createRefreshCookie(response.getRefreshToken()); + + AuthResponse safeResponse = AuthResponse.builder() + .userId(response.getUserId()).email(response.getEmail()) + .name(response.getName()).role(response.getRole()) + .phoneNumber(response.getPhoneNumber()).point(response.getPoint()) + .build(); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, accessCookie.toString()) + .header(HttpHeaders.SET_COOKIE, refreshCookie.toString()) + .body(safeResponse); + } + + @Operation(summary = "Access Token 재발급 API", description = "...") + @PostMapping("/reissue") + public ResponseEntity reissue(@CookieValue(value = "refresh", required = false) String refreshToken) { + try { + Map tokens = refreshTokenService.reissueTokens(refreshToken); + ResponseEntity.BodyBuilder responseBuilder = ResponseEntity.ok().header(HttpHeaders.AUTHORIZATION, "Bearer " + tokens.get("accessToken")); + if (tokens.containsKey("refreshToken")) { + responseBuilder.header(HttpHeaders.SET_COOKIE, cookieHelper.createRefreshCookie(tokens.get("refreshToken")).toString()); + } + responseBuilder.header(HttpHeaders.SET_COOKIE, cookieHelper.createAccessCookie(tokens.get("accessToken")).toString()); + return responseBuilder.body(Map.of("message", "토큰 갱신 성공")); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "Refresh Token이 유효하지 않거나 만료되었습니다.")); + } + } + + @Operation(summary = "로그아웃 API", description = "...") + @PostMapping("/logout") + public ResponseEntity logout(@CookieValue(value = "access", required = false) String accessToken) { + authService.logout(accessToken); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookieHelper.deleteCookie("access").toString()) + .header(HttpHeaders.SET_COOKIE, cookieHelper.deleteCookie("refresh").toString()) + .body(Map.of("message", "로그아웃 성공")); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/controller/AuthCookieHelper.java b/backend/src/main/java/org/sejongisc/backend/common/auth/controller/AuthCookieHelper.java new file mode 100644 index 00000000..87d0d63f --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/controller/AuthCookieHelper.java @@ -0,0 +1,36 @@ +package org.sejongisc.backend.common.auth.controller; + +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +@Component +public class AuthCookieHelper { + + public ResponseCookie createAccessCookie(String token) { + return createCookie("access", token, 60L * 60); + } + + public ResponseCookie createRefreshCookie(String token) { + return createCookie("refresh", token, 60L * 60 * 24 * 14); + } + + public ResponseCookie deleteCookie(String name) { + return ResponseCookie.from(name, "") + .httpOnly(true) + .secure(true) + .sameSite("None") + .path("/") + .maxAge(0) + .build(); + } + + private ResponseCookie createCookie(String name, String value, long maxAge) { + return ResponseCookie.from(name, value) + .httpOnly(true) + .secure(true) + .sameSite("None") + .path("/") + .maxAge(maxAge) + .build(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/auth/controller/EmailController.java b/backend/src/main/java/org/sejongisc/backend/common/auth/controller/EmailController.java similarity index 93% rename from backend/src/main/java/org/sejongisc/backend/auth/controller/EmailController.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/controller/EmailController.java index d0ecfece..f40149d9 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/controller/EmailController.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/controller/EmailController.java @@ -1,12 +1,11 @@ -package org.sejongisc.backend.auth.controller; +package org.sejongisc.backend.common.auth.controller; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.sejongisc.backend.auth.service.EmailService; +import org.sejongisc.backend.common.auth.service.EmailService; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; diff --git a/backend/src/main/java/org/sejongisc/backend/auth/dto/LoginRequest.java b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/AuthRequest.java similarity index 59% rename from backend/src/main/java/org/sejongisc/backend/auth/dto/LoginRequest.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/dto/AuthRequest.java index e16f5ef2..125caf4c 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/dto/LoginRequest.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/AuthRequest.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.auth.dto; +package org.sejongisc.backend.common.auth.dto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; @@ -11,22 +11,18 @@ @Setter @NoArgsConstructor @AllArgsConstructor -@Schema( - name = "LoginRequest", - description = "일반 로그인 요청 객체 (이메일과 비밀번호 입력)" -) -public class LoginRequest { +public class AuthRequest { @Schema( - description = "사용자 이메일 주소", - example = "testuser@example.com", + description = "사용자 학번 (String)", + example = "21001001", requiredMode = Schema.RequiredMode.REQUIRED ) - @NotBlank(message = "이메일은 필수 입력값입니다.") - private String email; + @NotBlank(message = "학번은 필수 입력값입니다.") + private String studentId; @Schema( - description = "사용자 비밀번호 (8자 이상, 특수문자 포함 권장)", + description = "사용자 비밀번호 (8자 이상, 특수문자 포함)", example = "1234abcd!", requiredMode = Schema.RequiredMode.REQUIRED ) diff --git a/backend/src/main/java/org/sejongisc/backend/auth/dto/LoginResponse.java b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/AuthResponse.java similarity index 85% rename from backend/src/main/java/org/sejongisc/backend/auth/dto/LoginResponse.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/dto/AuthResponse.java index 7a9c5284..36768653 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/dto/LoginResponse.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/AuthResponse.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.auth.dto; +package org.sejongisc.backend.common.auth.dto; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; @@ -11,11 +11,7 @@ @Getter @Builder @AllArgsConstructor -@Schema( - name = "LoginResponse", - description = "로그인 성공 시 반환되는 응답 객체" -) -public class LoginResponse { +public class AuthResponse { @Schema( description = "Access Token (JWT 형식, API 요청 시 Authorization 헤더에 사용)", @@ -48,8 +44,8 @@ public class LoginResponse { private String name; @Schema( - description = "사용자 역할 (예: USER, ADMIN)", - example = "USER" + description = "사용자 직위", + example = "PRESIDENT" ) private Role role; diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/CustomUserDetails.java b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/CustomUserDetails.java similarity index 69% rename from backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/CustomUserDetails.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/dto/CustomUserDetails.java index c5069d7d..5b86c3b0 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/CustomUserDetails.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/CustomUserDetails.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.common.auth.springsecurity; +package org.sejongisc.backend.common.auth.dto; import lombok.Getter; import org.sejongisc.backend.user.entity.Role; @@ -17,10 +17,10 @@ public class CustomUserDetails implements UserDetails { private final UUID userId; private final String name; private final String email; - private final String password; - private final String phoneNumber; + private final String password; // TODO : 보안을 위해 password 필드 제거 고려 + private final String phoneNumber; // TODO : 굳이 있어야하나? 제거 고려 private final Role role; - private final Integer point; + private final Integer point; // TODO : 사용자 포인트는 가변적이기 때문에, 제거 고려 public CustomUserDetails(User user) { this.userId = user.getUserId(); @@ -35,7 +35,10 @@ public CustomUserDetails(User user) { @Override public Collection getAuthorities() { - return List.of(new SimpleGrantedAuthority(role.name())); + // role.name()이 "PRESIDENT"라면 "ROLE_PRESIDENT"로 변환해서 반환해야 함 + // Spring Security에서는 권한 앞에 "ROLE_" 접두사를 붙이는 것이 관례임 + // hasRole("PRESIDENT") 같은 메서드 호출 시 "ROLE_PRESIDENT"와 매칭되기 때문 + return List.of(new SimpleGrantedAuthority("ROLE_" + role.name())); } @Override @@ -67,4 +70,4 @@ public boolean isCredentialsNonExpired() { public boolean isEnabled() { return UserDetails.super.isEnabled(); } -} +} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/dto/SignupRequest.java b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/SignupRequest.java new file mode 100644 index 00000000..216cb8f6 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/SignupRequest.java @@ -0,0 +1,63 @@ +package org.sejongisc.backend.common.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.*; +import org.sejongisc.backend.user.entity.Gender; +import org.sejongisc.backend.user.entity.Role; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "회원가입 요청 DTO") +public class SignupRequest { + public static final String STUDENT_ID_REGEX = "^[0-9]{8}$"; // 8자리 학번 + public static final String PHONE_FORMAT_REGEX = "^010-\\d{3,4}-\\d{4}$"; // xxx-xxxx-xxxx 형식 + + @NotBlank(message = "성함은 필수입니다.") + @Schema(description = "성함", example = "홍길동") + private String name; + + @NotBlank(message = "학번은 필수입니다.") + @Pattern(regexp = STUDENT_ID_REGEX, message = "학번은 8자리 숫자여야 합니다.") + @Schema(description = "학번 (로그인 ID로 사용)", example = "21010000") + private String studentId; + + @NotBlank(message = "비밀번호는 필수입니다.") + @Schema(description = "비밀번호 (대소문자/숫자/특수문자 포함)", example = "Sira1234!") + private String password; + + @NotBlank(message = "전화번호는 필수입니다.") + @Pattern(regexp = PHONE_FORMAT_REGEX, message = "전화번호 형식이 올바르지 않습니다. (예: 010-1234-5678)") + @Schema(description = "전화번호", example = "010-1234-5678") + private String phoneNumber; + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "유효한 이메일 형식이 아닙니다.") + @Schema(description = "비밀번호 재설정용 이메일", example = "sira@sejong.ac.kr") + private String email; + + @NotNull(message = "성별은 필수입니다.") + @Schema(description = "성별", example = "MALE") + private Gender gender; + + @Schema(description = "단과대학", example = "인공지능융합대학") + private String college; + + @Schema(description = "학과", example = "컴퓨터공학과") + private String department; + + @Schema(description = "기수", example = "25") + private Integer generation; + + @Schema(description = "활동팀", example = "금융IT") + private String teamName; + + @Schema(description = "기타 특이사항 (선배/운영부 등 가입 목적)", example = "10기 운영진 가입 신청입니다.") + private String remark; +} diff --git a/backend/src/main/java/org/sejongisc/backend/auth/dto/SignupResponse.java b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/SignupResponse.java similarity index 97% rename from backend/src/main/java/org/sejongisc/backend/auth/dto/SignupResponse.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/dto/SignupResponse.java index d87f230a..dab21c8a 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/dto/SignupResponse.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/SignupResponse.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.auth.dto; +package org.sejongisc.backend.common.auth.dto; import com.fasterxml.jackson.annotation.JsonFormat; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/backend/src/main/java/org/sejongisc/backend/auth/dto/GithubTokenResponse.java b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/GithubTokenResponse.java similarity index 95% rename from backend/src/main/java/org/sejongisc/backend/auth/dto/GithubTokenResponse.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/GithubTokenResponse.java index cdebc9a8..925adf99 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/dto/GithubTokenResponse.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/GithubTokenResponse.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.auth.dto; +package org.sejongisc.backend.common.auth.dto.oauth; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/backend/src/main/java/org/sejongisc/backend/auth/oauth/GithubUserInfoAdapter.java b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/GithubUserInfoAdapter.java similarity index 84% rename from backend/src/main/java/org/sejongisc/backend/auth/oauth/GithubUserInfoAdapter.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/GithubUserInfoAdapter.java index f33eb4c6..0b1d9a09 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/oauth/GithubUserInfoAdapter.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/GithubUserInfoAdapter.java @@ -1,7 +1,6 @@ -package org.sejongisc.backend.auth.oauth; +package org.sejongisc.backend.common.auth.dto.oauth; -import org.sejongisc.backend.auth.dto.GithubUserInfoResponse; -import org.sejongisc.backend.auth.entity.AuthProvider; +import org.sejongisc.backend.common.auth.entity.AuthProvider; import java.util.Optional; diff --git a/backend/src/main/java/org/sejongisc/backend/auth/dto/GithubUserInfoResponse.java b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/GithubUserInfoResponse.java similarity index 96% rename from backend/src/main/java/org/sejongisc/backend/auth/dto/GithubUserInfoResponse.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/GithubUserInfoResponse.java index 74af77fd..1daa26c6 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/dto/GithubUserInfoResponse.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/GithubUserInfoResponse.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.auth.dto; +package org.sejongisc.backend.common.auth.dto.oauth; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/backend/src/main/java/org/sejongisc/backend/auth/dto/GoogleTokenResponse.java b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/GoogleTokenResponse.java similarity index 97% rename from backend/src/main/java/org/sejongisc/backend/auth/dto/GoogleTokenResponse.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/GoogleTokenResponse.java index a286adec..60cfb67e 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/dto/GoogleTokenResponse.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/GoogleTokenResponse.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.auth.dto; +package org.sejongisc.backend.common.auth.dto.oauth; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/backend/src/main/java/org/sejongisc/backend/auth/oauth/GoogleUserInfoAdapter.java b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/GoogleUserInfoAdapter.java similarity index 83% rename from backend/src/main/java/org/sejongisc/backend/auth/oauth/GoogleUserInfoAdapter.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/GoogleUserInfoAdapter.java index 286d4b68..7ce0cca7 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/oauth/GoogleUserInfoAdapter.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/GoogleUserInfoAdapter.java @@ -1,7 +1,6 @@ -package org.sejongisc.backend.auth.oauth; +package org.sejongisc.backend.common.auth.dto.oauth; -import org.sejongisc.backend.auth.dto.GoogleUserInfoResponse; -import org.sejongisc.backend.auth.entity.AuthProvider; +import org.sejongisc.backend.common.auth.entity.AuthProvider; import java.util.Optional; diff --git a/backend/src/main/java/org/sejongisc/backend/auth/dto/GoogleUserInfoResponse.java b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/GoogleUserInfoResponse.java similarity index 95% rename from backend/src/main/java/org/sejongisc/backend/auth/dto/GoogleUserInfoResponse.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/GoogleUserInfoResponse.java index ad3a0bcf..814928d5 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/dto/GoogleUserInfoResponse.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/GoogleUserInfoResponse.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.auth.dto; +package org.sejongisc.backend.common.auth.dto.oauth; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/backend/src/main/java/org/sejongisc/backend/auth/dto/KakaoTokenResponse.java b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/KakaoTokenResponse.java similarity index 97% rename from backend/src/main/java/org/sejongisc/backend/auth/dto/KakaoTokenResponse.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/KakaoTokenResponse.java index ec0f8aca..30e12a4a 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/dto/KakaoTokenResponse.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/KakaoTokenResponse.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.auth.dto; +package org.sejongisc.backend.common.auth.dto.oauth; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/backend/src/main/java/org/sejongisc/backend/auth/oauth/KakaoUserInfoAdapter.java b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/KakaoUserInfoAdapter.java similarity index 86% rename from backend/src/main/java/org/sejongisc/backend/auth/oauth/KakaoUserInfoAdapter.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/KakaoUserInfoAdapter.java index 642e77fa..208d1cdc 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/oauth/KakaoUserInfoAdapter.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/KakaoUserInfoAdapter.java @@ -1,7 +1,6 @@ -package org.sejongisc.backend.auth.oauth; +package org.sejongisc.backend.common.auth.dto.oauth; -import org.sejongisc.backend.auth.dto.KakaoUserInfoResponse; -import org.sejongisc.backend.auth.entity.AuthProvider; +import org.sejongisc.backend.common.auth.entity.AuthProvider; import java.util.Optional; diff --git a/backend/src/main/java/org/sejongisc/backend/auth/dto/KakaoUserInfoResponse.java b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/KakaoUserInfoResponse.java similarity index 99% rename from backend/src/main/java/org/sejongisc/backend/auth/dto/KakaoUserInfoResponse.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/KakaoUserInfoResponse.java index b4bb2ddc..7ee73510 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/dto/KakaoUserInfoResponse.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/KakaoUserInfoResponse.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.auth.dto; +package org.sejongisc.backend.common.auth.dto.oauth; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/backend/src/main/java/org/sejongisc/backend/auth/oauth/OauthUserInfo.java b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/OauthUserInfo.java similarity index 64% rename from backend/src/main/java/org/sejongisc/backend/auth/oauth/OauthUserInfo.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/OauthUserInfo.java index 849bdd18..bd3b5cb7 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/oauth/OauthUserInfo.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/dto/oauth/OauthUserInfo.java @@ -1,6 +1,6 @@ -package org.sejongisc.backend.auth.oauth; +package org.sejongisc.backend.common.auth.dto.oauth; -import org.sejongisc.backend.auth.entity.AuthProvider; +import org.sejongisc.backend.common.auth.entity.AuthProvider; public interface OauthUserInfo { String getProviderUid(); diff --git a/backend/src/main/java/org/sejongisc/backend/auth/entity/AuthProvider.java b/backend/src/main/java/org/sejongisc/backend/common/auth/entity/AuthProvider.java similarity index 89% rename from backend/src/main/java/org/sejongisc/backend/auth/entity/AuthProvider.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/entity/AuthProvider.java index 6f488d8d..dc5bc582 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/entity/AuthProvider.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/entity/AuthProvider.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.auth.entity; +package org.sejongisc.backend.common.auth.entity; public enum AuthProvider { GOOGLE, // 구글 diff --git a/backend/src/main/java/org/sejongisc/backend/auth/entity/RefreshToken.java b/backend/src/main/java/org/sejongisc/backend/common/auth/entity/RefreshToken.java similarity index 88% rename from backend/src/main/java/org/sejongisc/backend/auth/entity/RefreshToken.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/entity/RefreshToken.java index 8f1b390e..ba166217 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/entity/RefreshToken.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/entity/RefreshToken.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.auth.entity; +package org.sejongisc.backend.common.auth.entity; import jakarta.persistence. *; import lombok.*; diff --git a/backend/src/main/java/org/sejongisc/backend/auth/entity/UserOauthAccount.java b/backend/src/main/java/org/sejongisc/backend/common/auth/entity/UserOauthAccount.java similarity index 96% rename from backend/src/main/java/org/sejongisc/backend/auth/entity/UserOauthAccount.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/entity/UserOauthAccount.java index e85ae311..24aed5cf 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/entity/UserOauthAccount.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/entity/UserOauthAccount.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.auth.entity; +package org.sejongisc.backend.common.auth.entity; import jakarta.persistence.*; import lombok.*; diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/JwtAuthenticationFilter.java b/backend/src/main/java/org/sejongisc/backend/common/auth/filter/JwtAuthenticationFilter.java similarity index 68% rename from backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/JwtAuthenticationFilter.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/filter/JwtAuthenticationFilter.java index 34d20242..77264785 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/JwtAuthenticationFilter.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/filter/JwtAuthenticationFilter.java @@ -1,8 +1,6 @@ -package org.sejongisc.backend.common.auth.springsecurity; +package org.sejongisc.backend.common.auth.filter; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import io.jsonwebtoken.JwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -11,12 +9,12 @@ import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.constraints.NotNull; import java.io.IOException; -import java.util.List; -import java.util.Optional; +import java.util.Arrays; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.sejongisc.backend.common.auth.jwt.JwtParser; +import org.sejongisc.backend.common.config.security.SecurityConstants; import org.sejongisc.backend.common.exception.ErrorCode; import org.sejongisc.backend.common.exception.ErrorResponse; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -35,45 +33,24 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtParser jwtParser; private final AntPathMatcher pathMatcher = new AntPathMatcher(); - - - private static final List EXCLUDE_PATTERNS = List.of( - "/api/auth/signup", - "/api/auth/login", - "/api/auth/login/**", - "/api/auth/logout", - "/api/auth/reissue", - "/v3/api-docs/**", - "/swagger-ui/**", - "/swagger-ui/index.html", - "/swagger-resources/**", - "/webjars/**", - "/login/**", - "/oauth2/**" - ); + private final ObjectMapper objectMapper; @Override protected void doFilterInternal(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, - @NotNull FilterChain filterChain) - throws ServletException, IOException { + @NotNull FilterChain filterChain) throws ServletException, IOException { String requestURI = request.getRequestURI(); // 인증 제외 경로 - if (shouldNotFilter(request)) { - filterChain.doFilter(request, response); - return; - } - - if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { + // 브라우저가 실제 요청 전에 서버에 보내는 CORS 예비 요청(Preflight 요청)은 OPTIONS 메서드 사용 (JWT 검사 제외) + if (shouldNotFilter(request) || "OPTIONS".equalsIgnoreCase(request.getMethod())) { filterChain.doFilter(request, response); return; } try { - String token = resolveToken(request); - + String token = resolveTokenFromHeader(request); if (token == null) { token = resolveTokenFromCookie(request); } @@ -85,18 +62,12 @@ protected void doFilterInternal(@NotNull HttpServletRequest request, } else { log.warn("토큰이 없거나 유효하지 않음"); } - + filterChain.doFilter(request, response); } catch (JwtException e) { log.error("JWT validation failed: {}", e.getMessage(), e); - ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.INVALID_ACCESS_TOKEN); - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType("application/json;charset=UTF-8"); - response.getWriter().write(toJson(errorResponse)); + sendErrorResponse(response, ErrorCode.INVALID_ACCESS_TOKEN); return; // 예외 시 여기서 중단 } - - // 필터 체인은 항상 마지막에 한 번만 호출 - filterChain.doFilter(request, response); } @@ -110,7 +81,7 @@ protected boolean shouldNotFilter(HttpServletRequest request) { return true; } - boolean excluded = EXCLUDE_PATTERNS.stream() + boolean excluded = Arrays.stream(SecurityConstants.WHITELIST_URLS) .anyMatch(pattern -> pathMatcher.match(pattern, path)); // 어떤 요청이 필터 예외로 분류됐는지 콘솔에 표시 @@ -119,7 +90,14 @@ protected boolean shouldNotFilter(HttpServletRequest request) { return excluded; } - private String resolveToken(HttpServletRequest request) { + private void sendErrorResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException { + ErrorResponse errorResponse = ErrorResponse.of(errorCode); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } + + private String resolveTokenFromHeader(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7); @@ -136,14 +114,6 @@ private String resolveTokenFromCookie(HttpServletRequest request) { return cookie.getValue(); } } - return null; } - - private String toJson(ErrorResponse errorResponse) throws JsonProcessingException { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new JavaTimeModule()); - return mapper.writeValueAsString(errorResponse); - } - } \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/jwt/JwtParser.java b/backend/src/main/java/org/sejongisc/backend/common/auth/jwt/JwtParser.java index ac76865e..0c6bfca3 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/jwt/JwtParser.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/jwt/JwtParser.java @@ -5,8 +5,7 @@ import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; -import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetailsService; +import org.sejongisc.backend.common.auth.service.CustomUserDetailsService; import org.sejongisc.backend.user.entity.Role; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -47,8 +46,8 @@ public boolean validationToken(String token) { // Authentication 생성 public UsernamePasswordAuthenticationToken getAuthentication(String token) { Claims claims = parseClaims(token); + // TODO : 유지보수성을 위해 클레임 키 상수화 고려 String userId = claims.get("uid", String.class); - String roleStr = claims.get("role", String.class); if (roleStr == null) { throw new JwtException("JWT에 role 클레임이 없습니다."); @@ -66,7 +65,7 @@ public UsernamePasswordAuthenticationToken getAuthentication(String token) { } // DB에서 다시 유저를 불러오기 (CustomUserDetailsService 사용) - UserDetails userDetails = customUserDetailsService.loadUserByUsername(userId); + UserDetails userDetails = customUserDetailsService.loadUserByUsername(userId); // TODO : 성능 고려해서 DB 조회 제거 고민 log.debug("인증 객체 생성 완료"); return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); diff --git a/backend/src/main/java/org/sejongisc/backend/auth/repository/RefreshTokenRepository.java b/backend/src/main/java/org/sejongisc/backend/common/auth/repository/RefreshTokenRepository.java similarity index 75% rename from backend/src/main/java/org/sejongisc/backend/auth/repository/RefreshTokenRepository.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/repository/RefreshTokenRepository.java index 362282de..5d271507 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/repository/RefreshTokenRepository.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/repository/RefreshTokenRepository.java @@ -1,6 +1,6 @@ -package org.sejongisc.backend.auth.repository; +package org.sejongisc.backend.common.auth.repository; -import org.sejongisc.backend.auth.entity.RefreshToken; +import org.sejongisc.backend.common.auth.entity.RefreshToken; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; diff --git a/backend/src/main/java/org/sejongisc/backend/auth/dao/UserOauthAccountRepository.java b/backend/src/main/java/org/sejongisc/backend/common/auth/repository/UserOauthAccountRepository.java similarity index 70% rename from backend/src/main/java/org/sejongisc/backend/auth/dao/UserOauthAccountRepository.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/repository/UserOauthAccountRepository.java index 21d92029..f47bc2fa 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/dao/UserOauthAccountRepository.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/repository/UserOauthAccountRepository.java @@ -1,7 +1,7 @@ -package org.sejongisc.backend.auth.dao; +package org.sejongisc.backend.common.auth.repository; -import org.sejongisc.backend.auth.entity.AuthProvider; -import org.sejongisc.backend.auth.entity.UserOauthAccount; +import org.sejongisc.backend.common.auth.entity.AuthProvider; +import org.sejongisc.backend.common.auth.entity.UserOauthAccount; import org.sejongisc.backend.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/backend/src/main/java/org/sejongisc/backend/auth/service/LoginServiceImpl.java b/backend/src/main/java/org/sejongisc/backend/common/auth/service/AuthService.java similarity index 70% rename from backend/src/main/java/org/sejongisc/backend/auth/service/LoginServiceImpl.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/service/AuthService.java index 1ab7d953..3950c2ad 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/service/LoginServiceImpl.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/service/AuthService.java @@ -1,31 +1,28 @@ -package org.sejongisc.backend.auth.service; +package org.sejongisc.backend.common.auth.service; -import jakarta.servlet.http.HttpServletRequest; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.sejongisc.backend.auth.repository.RefreshTokenRepository; +import org.sejongisc.backend.common.auth.entity.RefreshToken; +import org.sejongisc.backend.common.auth.repository.RefreshTokenRepository; import org.sejongisc.backend.common.auth.jwt.JwtParser; import org.sejongisc.backend.common.auth.jwt.JwtProvider; -import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; -import org.sejongisc.backend.user.dao.UserRepository; -import org.sejongisc.backend.auth.dto.LoginRequest; -import org.sejongisc.backend.auth.dto.LoginResponse; +import org.sejongisc.backend.user.repository.UserRepository; +import org.sejongisc.backend.common.auth.dto.AuthRequest; +import org.sejongisc.backend.common.auth.dto.AuthResponse; import org.sejongisc.backend.user.entity.User; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.sejongisc.backend.user.util.PasswordPolicyValidator; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; -import java.sql.Ref; import java.util.UUID; @Slf4j @Service @RequiredArgsConstructor -public class LoginServiceImpl implements LoginService { +public class AuthService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; @@ -33,16 +30,14 @@ public class LoginServiceImpl implements LoginService { private final RefreshTokenRepository refreshTokenRepository; private final JwtParser jwtParser; - @Override @Transactional - public LoginResponse login(LoginRequest request) { - User user = userRepository.findUserByEmail(request.getEmail()) + public AuthResponse login(AuthRequest request) { + User user = userRepository.findByStudentId(request.getStudentId()) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - - if (user.getPasswordHash() == null || !passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) { + String trimmedPassword = PasswordPolicyValidator.getValidatedPassword(request.getPassword()); + if (user.getPasswordHash() == null || !passwordEncoder.matches(trimmedPassword, user.getPasswordHash())) { throw new CustomException(ErrorCode.UNAUTHORIZED); } - String accessToken = jwtProvider.createToken(user.getUserId(), user.getRole(), user.getEmail()); String refreshToken = jwtProvider.createRefreshToken(user.getUserId()); @@ -51,7 +46,7 @@ public LoginResponse login(LoginRequest request) { .ifPresent(refreshTokenRepository::delete); refreshTokenRepository.save( - org.sejongisc.backend.auth.entity.RefreshToken.builder() + RefreshToken.builder() .userId(user.getUserId()) .token(refreshToken) .build() @@ -59,7 +54,7 @@ public LoginResponse login(LoginRequest request) { log.info("RefreshToken 저장 완료: userId={}", user.getUserId()); - return LoginResponse.builder() + return AuthResponse.builder() .accessToken(accessToken) .refreshToken(refreshToken) .userId(user.getUserId()) @@ -70,7 +65,6 @@ public LoginResponse login(LoginRequest request) { .build(); } - @Override @Transactional public void logout(String accessToken) { UUID userId = jwtParser.getUserIdFromToken(accessToken); diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/CustomUserDetailsService.java b/backend/src/main/java/org/sejongisc/backend/common/auth/service/CustomUserDetailsService.java similarity index 62% rename from backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/CustomUserDetailsService.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/service/CustomUserDetailsService.java index 93ba2658..57b4ed08 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/CustomUserDetailsService.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/service/CustomUserDetailsService.java @@ -1,9 +1,10 @@ -package org.sejongisc.backend.common.auth.springsecurity; +package org.sejongisc.backend.common.auth.service; import lombok.RequiredArgsConstructor; +import org.sejongisc.backend.common.auth.dto.CustomUserDetails; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; -import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.user.repository.UserRepository; import org.sejongisc.backend.user.entity.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; @@ -21,18 +22,13 @@ public class CustomUserDetailsService implements UserDetailsService { @Override public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException { - UUID uuid; try { - uuid = UUID.fromString(userId); + UUID uuidUserId = UUID.fromString(userId); + User findUser = userRepository.findById(uuidUserId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + return new CustomUserDetails(findUser); } catch (IllegalArgumentException e) { - throw new CustomException(ErrorCode.INVALID_ACCESS_TOKEN); + throw new CustomException(ErrorCode.INVALID_INPUT); } - User findUser = userRepository.findById(uuid).orElseThrow( - () -> new CustomException(ErrorCode.USER_NOT_FOUND) - ); - - return new CustomUserDetails(findUser); - } - } diff --git a/backend/src/main/java/org/sejongisc/backend/auth/service/EmailService.java b/backend/src/main/java/org/sejongisc/backend/common/auth/service/EmailService.java similarity index 96% rename from backend/src/main/java/org/sejongisc/backend/auth/service/EmailService.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/service/EmailService.java index 699902f2..05dbd20f 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/service/EmailService.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/service/EmailService.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.auth.service; +package org.sejongisc.backend.common.auth.service; import jakarta.mail.Message; import jakarta.mail.MessagingException; @@ -9,15 +9,14 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.validator.routines.EmailValidator; -import org.sejongisc.backend.auth.config.EmailProperties; +import org.sejongisc.backend.common.config.EmailProperties; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; -import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.user.repository.UserRepository; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.mail.MailSendException; import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; import org.thymeleaf.context.Context; diff --git a/backend/src/main/java/org/sejongisc/backend/auth/service/RefreshTokenServiceImpl.java b/backend/src/main/java/org/sejongisc/backend/common/auth/service/RefreshTokenService.java similarity index 90% rename from backend/src/main/java/org/sejongisc/backend/auth/service/RefreshTokenServiceImpl.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/service/RefreshTokenService.java index a069f7e6..d7cc614a 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/service/RefreshTokenServiceImpl.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/service/RefreshTokenService.java @@ -1,15 +1,15 @@ -package org.sejongisc.backend.auth.service; +package org.sejongisc.backend.common.auth.service; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.sejongisc.backend.auth.entity.RefreshToken; -import org.sejongisc.backend.auth.repository.RefreshTokenRepository; +import org.sejongisc.backend.common.auth.entity.RefreshToken; +import org.sejongisc.backend.common.auth.repository.RefreshTokenRepository; import org.sejongisc.backend.common.auth.jwt.JwtProvider; import org.sejongisc.backend.common.auth.jwt.TokenEncryptor; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; -import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.user.repository.UserRepository; import org.sejongisc.backend.user.entity.User; import org.springframework.stereotype.Service; @@ -23,16 +23,18 @@ @Slf4j @Service @RequiredArgsConstructor -public class RefreshTokenServiceImpl implements RefreshTokenService { +public class RefreshTokenService { private final RefreshTokenRepository refreshTokenRepository; private final UserRepository userRepository; private final JwtProvider jwtProvider; private final TokenEncryptor tokenEncryptor; - @Override @Transactional public Map reissueTokens(String encryptedRefreshToken) { + if (encryptedRefreshToken == null || encryptedRefreshToken.isEmpty()) { + throw new CustomException(ErrorCode.MISSING_REFRESH_TOKEN); + } try { // 전달받은 refreshToken 복호화 String rawRefreshToken = tokenEncryptor.decrypt(encryptedRefreshToken); @@ -88,14 +90,12 @@ public Map reissueTokens(String encryptedRefreshToken) { } } - @Override @Transactional public void deleteByUserId(UUID userId) { refreshTokenRepository.deleteByUserId(userId); log.info("RefreshToken deleted for userId={}", userId); } - @Override @Transactional public void saveOrUpdateToken(UUID userId, String refreshToken) { refreshTokenRepository.findByUserId(userId) diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/GithubServiceImpl.java b/backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/GithubServiceImpl.java new file mode 100644 index 00000000..0ce8d365 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/GithubServiceImpl.java @@ -0,0 +1,440 @@ +package org.sejongisc.backend.common.auth.service.oauth2; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sejongisc.backend.common.auth.dto.oauth.GithubTokenResponse; +import org.sejongisc.backend.common.auth.dto.oauth.GithubUserInfoResponse; +import org.sejongisc.backend.common.auth.entity.AuthProvider; +import org.sejongisc.backend.common.auth.entity.UserOauthAccount; +import org.sejongisc.backend.common.auth.jwt.JwtProvider; +import org.sejongisc.backend.common.auth.repository.UserOauthAccountRepository; +import org.sejongisc.backend.common.auth.service.RefreshTokenService; +import org.sejongisc.backend.user.entity.Role; +import org.sejongisc.backend.user.entity.User; +import org.sejongisc.backend.user.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.env.Environment; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +@Slf4j +@Service("GITHUB") +public class GithubServiceImpl implements Oauth2Service { + + private final String clientId; + private final String clientSecret; + + private final String TOKEN_URL; + private final String USERINFO_URL; + + @Autowired + public GithubServiceImpl( + @Value("${github.client.id}") String clientId, + @Value("${github.client.secret}") String clientSecret) { + this.clientId = clientId; + this.clientSecret = clientSecret; + this.TOKEN_URL = "https://github.com/login/oauth/access_token"; + this.USERINFO_URL = "https://api.github.com/user"; + } + + // ✅ 테스트용 생성자 + public GithubServiceImpl(String clientId, String clientSecret, + String tokenUrl, String userInfoUrl) { + this.clientId = clientId; + this.clientSecret = clientSecret; + this.TOKEN_URL = tokenUrl; + this.USERINFO_URL = userInfoUrl; + } + + @Override + public GithubTokenResponse getAccessToken(String code) { + GithubTokenResponse tokenResponse = WebClient.create(TOKEN_URL).post() + .uri(uriBuilder -> uriBuilder.build(true)) + .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .body(BodyInserters.fromFormData("client_id", clientId) + .with("client_secret", clientSecret) + .with("code", code)) + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, + clientResponse -> Mono.error(new RuntimeException("Invalid Parameter"))) + .onStatus(HttpStatusCode::is5xxServerError, + clientResponse -> Mono.error(new RuntimeException("Internal Server Error"))) + .bodyToMono(GithubTokenResponse.class) + .block(); + + if (tokenResponse == null || tokenResponse.getAccessToken() == null) { + throw new RuntimeException("Token response is empty"); + } + + Function mask = token -> { + if(token == null || token.length() < 8) return "****"; + return token.substring(0, 4) + "..." + token.substring(token.length() - 4); + }; + + log.debug(" [Github Service] Access Token ------> {}", mask.apply(tokenResponse.getAccessToken())); + log.debug(" [Github Service] Scope ------> {}", mask.apply(tokenResponse.getScope())); + + return tokenResponse; + } + + @Override + public GithubUserInfoResponse getUserInfo(String accessToken) { + GithubUserInfoResponse userInfo = WebClient.create(USERINFO_URL).get() + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, + clientResponse -> Mono.error(new RuntimeException("Invalid Parameter"))) + .onStatus(HttpStatusCode::is5xxServerError, + clientResponse -> Mono.error(new RuntimeException("Internal Server Error"))) + .bodyToMono(GithubUserInfoResponse.class) + .block(); + + if (userInfo == null) { + throw new RuntimeException("UserInfo response is empty"); + } + + if (log.isDebugEnabled()) { + log.debug(" [Github Service] ID ------> {}", userInfo.getId()); + log.debug(" [Github Service] Login ------> {}", userInfo.getLogin()); + log.debug(" [Github Service] Name ------> {}", userInfo.getName()); + } + + return userInfo; + } + + @Slf4j + @Service + @RequiredArgsConstructor + @Transactional + public static class CustomOAuth2UserService implements OAuth2UserService { + private final UserRepository userRepository; + private final UserOauthAccountRepository oauthAccountRepository; + + @Override + public OAuth2User loadUser(OAuth2UserRequest req) throws OAuth2AuthenticationException { + // log.info("[CustomOAuth2UserService] loadUser START"); + + OAuth2UserService delegate = + new DefaultOAuth2UserService(); + OAuth2User oAuth2User = delegate.loadUser(req); + + String provider = req.getClientRegistration().getRegistrationId(); // google, kakao, github + Map attrs = oAuth2User.getAttributes(); + + String providerUid; + String email; + String name; + + // log.info("[OAuth2] Provider = {}", provider); + if (log.isDebugEnabled()) { + log.debug("[OAuth2] Attributes = {}", attrs); + } + + switch (provider) { + case "google" -> { + providerUid = (String) attrs.get("sub"); + email = (String) attrs.get("email"); + name = (String) attrs.get("name"); + } + case "kakao" -> { + providerUid = attrs.get("id").toString(); + Map kakaoAccount = (Map) attrs.get("kakao_account"); + email = (String) kakaoAccount.get("email"); // null 가능 + Map profile = (Map) kakaoAccount.get("profile"); + name = (String) profile.get("nickname"); + } + case "github" -> { + providerUid = attrs.get("id").toString(); + email = (String) attrs.get("email"); + name = (String) attrs.get("login"); // GitHub은 login이 닉네임 + } + default -> throw new RuntimeException("지원하지 않는 provider: " + provider); + } + + // log.info("provider={}, providerUid={}, email={}, name={}", provider, providerUid, email, name); + + final String fProviderUid = providerUid; + final String fEmail = email; + final String fName = name; + final AuthProvider fAuthProvider = AuthProvider.valueOf(provider.toUpperCase()); + + User user = oauthAccountRepository + .findByProviderAndProviderUid(AuthProvider.from(provider), providerUid) + .map(UserOauthAccount::getUser) + .orElseGet(() -> { + User newUser = User.builder() + .email(email) + .name(name) + .role(Role.TEAM_MEMBER) + .build(); + User saved = userRepository.save(newUser); + + UserOauthAccount oauth = UserOauthAccount.builder() + .user(saved) + .provider(AuthProvider.from(provider)) + .providerUid(providerUid) + .build(); + oauthAccountRepository.save(oauth); + + return saved; + }); + + // log.info("[CustomOAuth2UserService] User resolved → returning OAuth2User"); + + Map attributes = new java.util.HashMap<>(); + attributes.put("provider", provider); // google / kakao / github + attributes.put("providerUid", providerUid); // 소셜 계정 UID + attributes.put("email", user.getEmail()); // DB email + attributes.put("name", user.getName()); + attributes.put("userId", user.getUserId()); // DB user uuid + + return new DefaultOAuth2User( + List.of(new SimpleGrantedAuthority("ROLE_TEAM_MEMBER")), + attributes, + "userId" // 또는 "email" -> email null 이면 id가 더 안전 + ); + + } + } + + @Slf4j + @Service + @RequiredArgsConstructor + @Transactional + public static class CustomOidcUserService extends OidcUserService { + + private final UserRepository userRepository; + private final UserOauthAccountRepository oauthAccountRepository; + + @Override + public OidcUser loadUser(OidcUserRequest req) throws OAuth2AuthenticationException { + // log.info("[CustomOidcUserService] Google OIDC loadUser START"); + + OidcUser oidcUser = super.loadUser(req); + + Map original = oidcUser.getAttributes(); + // log.info("OIDC claims: {}", original); + + // SuccessHandler가 필요로 하는 attributes 넣기 + String provider = "google"; // provider + String providerUid = (String) original.get("sub"); // 구글 고유 ID + String email = (String) original.get("email"); + String name = (String) original.get("name"); + + // 신규 가입 or 기존 유저 조회 + User user = oauthAccountRepository + .findByProviderAndProviderUid(AuthProvider.GOOGLE, providerUid) + .map(UserOauthAccount::getUser) + .orElseGet(() -> { + // (1) User 생성 + User newUser = User.builder() + .email(email) + .name(name) + .role(Role.TEAM_MEMBER) + .build(); + User savedUser = userRepository.save(newUser); + + // (2) UserOauthAccount 생성 + UserOauthAccount oauth = UserOauthAccount.builder() + .user(savedUser) + .provider(AuthProvider.GOOGLE) + .providerUid(providerUid) + .build(); + oauthAccountRepository.save(oauth); + + // log.info("[CustomOidcUserService] 신규 User 및 UserOauthAccount 생성됨"); + return savedUser; + }); + + Map attrs = new HashMap<>(original); + attrs.put("provider", provider); + attrs.put("providerUid", providerUid); + attrs.put("email", user.getEmail()); + attrs.put("name", user.getName()); + attrs.put("userId", user.getUserId()); // SuccessHandler가 필요로 함 + + // 여기서 attrs를 defaultOidcUser에 직접 넣음 + return new DefaultOidcUser( + oidcUser.getAuthorities(), + oidcUser.getIdToken(), + oidcUser.getUserInfo(), + "sub" + ) { + @Override + public Map getAttributes() { + return attrs; // 커스텀 attributes 적용 + } + }; + } + } + + @Slf4j + @Component + @RequiredArgsConstructor + public static class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtProvider jwtProvider; + private final RefreshTokenService refreshTokenService; + private final UserRepository userRepository; + private final UserOauthAccountRepository userOauthAccountRepository; + private final Environment env; + + @Value("${app.oauth2.redirect-success}") + private String redirectSuccessBase; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException { + + // log.info("[OAuth2SuccessHandler] SUCCESS HANDLER CALLED!"); + + if (!(authentication.getPrincipal() instanceof DefaultOAuth2User oauthUser)) { + throw new IllegalStateException("Unknown principal type: " + authentication.getPrincipal().getClass()); + } + + // 1. CustomOAuth2UserService에서 넣어준 attributes 가져오기 + Map attrs = oauthUser.getAttributes(); + + String providerStr = (String) attrs.get("provider"); + String providerUid = (String) attrs.get("providerUid"); + if (providerStr == null) { + throw new IllegalStateException("OAuth provider attribute missing from attributes"); + } + + AuthProvider provider = + switch (providerStr) { + case "kakao" -> AuthProvider.KAKAO; + case "github" -> AuthProvider.GITHUB; + case "google" -> AuthProvider.GOOGLE; + default -> throw new IllegalStateException("Unknown OAuth provider: " + providerStr); + }; + + + // log.info("[OAuth2SuccessHandler] provider={}, providerUid={}", provider, providerUid); + + // DB 조회 + UserOauthAccount account = userOauthAccountRepository + .findByProviderAndProviderUid(provider, providerUid) + .orElseThrow(() -> new RuntimeException("소셜 계정이 DB에 없습니다. (회원가입 필요)")); + + User user = userRepository.findById(account.getUser().getUserId()) + .orElseThrow(() -> new RuntimeException("User not found")); + + // JWT 생성 + String accessToken = jwtProvider.createToken( + user.getUserId(), + user.getRole(), + user.getEmail() + ); + + + // 4. RefreshToken 생성 + String refreshToken = jwtProvider.createRefreshToken(user.getUserId()); + // 5. RefreshToken 저장(DB or Redis) + refreshTokenService.saveOrUpdateToken(user.getUserId(), refreshToken); + + String[] activeProfiles = env.getActiveProfiles(); + List profiles = Arrays.asList(activeProfiles); + + boolean isProd = profiles.contains("prod"); + boolean isDev = profiles.contains("dev"); + + // SameSite, Secure 설정 (dev도 prod와 동일하게) + String sameSite = (isProd || isDev) ? "None" : "Lax"; + boolean secure = (isProd || isDev); + + // 도메인 설정 + String domain; + if (isProd) { + domain = "sjusisc.com"; // 운영 도메인 + } else if (isDev) { + domain = "sisc-web.duckdns.org"; // 개발 도메인 + } else { + domain = "localhost"; // 기본값 + } + + + + + // 6. HttpOnly 쿠키로 refreshToken 저장 + ResponseCookie.ResponseCookieBuilder accessCookieBuilder = ResponseCookie.from("access", accessToken) + .httpOnly(true) + .secure(secure) // 로컬=false, 배포=true + .sameSite(sameSite) // 로컬= "Lax", 배포="None" + .path("/") + .maxAge(60L * 60); // 1 hour + + // 로컬 환경에서는 domain 설정하지 않음 + if (isProd || isDev) { + accessCookieBuilder.domain(domain); + } + + ResponseCookie.ResponseCookieBuilder refreshCookieBuilder = ResponseCookie.from("refresh", refreshToken) + .httpOnly(true) + .secure(secure) + .sameSite(sameSite) + .path("/") + .maxAge(60L * 60 * 24 * 14); // 2 weeks + + // 로컬 환경에서는 domain 설정하지 않음 + if (isProd || isDev) { + refreshCookieBuilder.domain(domain); + } + + ResponseCookie accessCookie = accessCookieBuilder.build(); + ResponseCookie refreshCookie = refreshCookieBuilder.build(); + + + response.addHeader(HttpHeaders.SET_COOKIE, accessCookie.toString()); + response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString()); + + + // 7. 프론트로 redirect + // application-local.yml → http://localhost:5173/oauth/success + // application-prod.yml → https://sisc-web.duckdns.org/oauth/success + //String redirectUrl = redirectSuccessBase; + // + "?accessToken=" + accessToken + // + "&name=" + URLEncoder.encode(name, StandardCharsets.UTF_8) + // + "&userId=" + userId; + + // log.info("[OAuth2 Redirect] {}", redirectUrl); + + getRedirectStrategy().sendRedirect(request, response, redirectSuccessBase); + } + + } +} diff --git a/backend/src/main/java/org/sejongisc/backend/auth/service/GoogleServiceImpl.java b/backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/GoogleServiceImpl.java similarity index 95% rename from backend/src/main/java/org/sejongisc/backend/auth/service/GoogleServiceImpl.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/GoogleServiceImpl.java index aa4d02f5..ae5edcc2 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/service/GoogleServiceImpl.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/GoogleServiceImpl.java @@ -1,9 +1,8 @@ -package org.sejongisc.backend.auth.service; +package org.sejongisc.backend.common.auth.service.oauth2; -import io.netty.handler.codec.http.HttpHeaderValues; import lombok.extern.slf4j.Slf4j; -import org.sejongisc.backend.auth.dto.GoogleTokenResponse; -import org.sejongisc.backend.auth.dto.GoogleUserInfoResponse; +import org.sejongisc.backend.common.auth.dto.oauth.GoogleTokenResponse; +import org.sejongisc.backend.common.auth.dto.oauth.GoogleUserInfoResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; diff --git a/backend/src/main/java/org/sejongisc/backend/auth/service/KakaoServiceImpl.java b/backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/KakaoServiceImpl.java similarity index 95% rename from backend/src/main/java/org/sejongisc/backend/auth/service/KakaoServiceImpl.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/KakaoServiceImpl.java index 58158481..a13415f6 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/service/KakaoServiceImpl.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/KakaoServiceImpl.java @@ -1,15 +1,14 @@ -package org.sejongisc.backend.auth.service; +package org.sejongisc.backend.common.auth.service.oauth2; import io.netty.handler.codec.http.HttpHeaderValues; import lombok.extern.slf4j.Slf4j; -import org.sejongisc.backend.auth.dto.KakaoTokenResponse; -import org.sejongisc.backend.auth.dto.KakaoUserInfoResponse; +import org.sejongisc.backend.common.auth.dto.oauth.KakaoTokenResponse; +import org.sejongisc.backend.common.auth.dto.oauth.KakaoUserInfoResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.stereotype.Service; -import org.springframework.web.reactive.function.BodyInserter; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; diff --git a/backend/src/main/java/org/sejongisc/backend/auth/service/Oauth2Service.java b/backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/Oauth2Service.java similarity index 86% rename from backend/src/main/java/org/sejongisc/backend/auth/service/Oauth2Service.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/Oauth2Service.java index f7b5ed5a..b17d731f 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/service/Oauth2Service.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/Oauth2Service.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.auth.service; +package org.sejongisc.backend.common.auth.service.oauth2; public interface Oauth2Service { diff --git a/backend/src/main/java/org/sejongisc/backend/auth/service/OauthStateService.java b/backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/OauthStateService.java similarity index 80% rename from backend/src/main/java/org/sejongisc/backend/auth/service/OauthStateService.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/OauthStateService.java index ccfa2e27..a897794d 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/service/OauthStateService.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/OauthStateService.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.auth.service; +package org.sejongisc.backend.common.auth.service.oauth2; import jakarta.servlet.http.HttpSession; diff --git a/backend/src/main/java/org/sejongisc/backend/auth/service/OauthStateServiceImpl.java b/backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/OauthStateServiceImpl.java similarity index 82% rename from backend/src/main/java/org/sejongisc/backend/auth/service/OauthStateServiceImpl.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/OauthStateServiceImpl.java index 9c27eea8..e19e6892 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/service/OauthStateServiceImpl.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/OauthStateServiceImpl.java @@ -1,13 +1,9 @@ -package org.sejongisc.backend.auth.service; +package org.sejongisc.backend.common.auth.service.oauth2; import jakarta.servlet.http.HttpSession; import org.springframework.stereotype.Service; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; @Service public class OauthStateServiceImpl implements OauthStateService { diff --git a/backend/src/main/java/org/sejongisc/backend/auth/service/OauthUnlinkService.java b/backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/OauthUnlinkService.java similarity index 89% rename from backend/src/main/java/org/sejongisc/backend/auth/service/OauthUnlinkService.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/OauthUnlinkService.java index cd66e859..4244ce46 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/service/OauthUnlinkService.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/OauthUnlinkService.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.auth.service; +package org.sejongisc.backend.common.auth.service.oauth2; public interface OauthUnlinkService { diff --git a/backend/src/main/java/org/sejongisc/backend/auth/service/OauthUnlinkServiceImpl.java b/backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/OauthUnlinkServiceImpl.java similarity index 96% rename from backend/src/main/java/org/sejongisc/backend/auth/service/OauthUnlinkServiceImpl.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/OauthUnlinkServiceImpl.java index 6569a738..7ca0850f 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/service/OauthUnlinkServiceImpl.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/OauthUnlinkServiceImpl.java @@ -1,8 +1,8 @@ -package org.sejongisc.backend.auth.service; +package org.sejongisc.backend.common.auth.service.oauth2; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.sejongisc.backend.auth.exception.OauthUnlinkException; +import org.sejongisc.backend.common.auth.service.oauth2.exception.OauthUnlinkException; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.*; import org.springframework.stereotype.Service; diff --git a/backend/src/main/java/org/sejongisc/backend/auth/exception/OauthUnlinkException.java b/backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/exception/OauthUnlinkException.java similarity index 78% rename from backend/src/main/java/org/sejongisc/backend/auth/exception/OauthUnlinkException.java rename to backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/exception/OauthUnlinkException.java index af9908b1..15376a8e 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/exception/OauthUnlinkException.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/service/oauth2/exception/OauthUnlinkException.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.auth.exception; +package org.sejongisc.backend.common.auth.service.oauth2.exception; public class OauthUnlinkException extends RuntimeException { public OauthUnlinkException(String message) { diff --git a/backend/src/main/java/org/sejongisc/backend/auth/config/EmailProperties.java b/backend/src/main/java/org/sejongisc/backend/common/config/EmailProperties.java similarity index 93% rename from backend/src/main/java/org/sejongisc/backend/auth/config/EmailProperties.java rename to backend/src/main/java/org/sejongisc/backend/common/config/EmailProperties.java index c28e78ad..c2b8762f 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/config/EmailProperties.java +++ b/backend/src/main/java/org/sejongisc/backend/common/config/EmailProperties.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.auth.config; +package org.sejongisc.backend.common.config; import java.time.Duration; import lombok.Getter; diff --git a/backend/src/main/java/org/sejongisc/backend/common/config/PrimaryDataSourceConfig.java b/backend/src/main/java/org/sejongisc/backend/common/config/db/PrimaryDataSourceConfig.java similarity index 95% rename from backend/src/main/java/org/sejongisc/backend/common/config/PrimaryDataSourceConfig.java rename to backend/src/main/java/org/sejongisc/backend/common/config/db/PrimaryDataSourceConfig.java index 2d0fbf72..46dc6804 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/config/PrimaryDataSourceConfig.java +++ b/backend/src/main/java/org/sejongisc/backend/common/config/db/PrimaryDataSourceConfig.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.common.config; +package org.sejongisc.backend.common.config.db; import jakarta.persistence.EntityManagerFactory; import org.springframework.beans.factory.annotation.Qualifier; @@ -73,14 +73,14 @@ public LocalContainerEntityManagerFactoryBean primaryEntityManagerFactory( Map jpaProps = new HashMap<>(); jpaProps.put("hibernate.dialect", "org.hibernate.dialect.PostgreSQLDialect"); - // ddl-auto는 yml로 관리 권장 - jpaProps.put("hibernate.hbm2ddl.auto", "update"); + + jpaProps.put("hibernate.hbm2ddl.auto", "create"); return builder .dataSource(dataSource) .packages( "org.sejongisc.backend.attendance.entity", - "org.sejongisc.backend.auth.entity", + "org.sejongisc.backend.common.auth.entity", "org.sejongisc.backend.backtest.entity", "org.sejongisc.backend.betting.entity", "org.sejongisc.backend.board.entity", diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/config/RestTemplateConfig.java b/backend/src/main/java/org/sejongisc/backend/common/config/db/RestTemplateConfig.java similarity index 93% rename from backend/src/main/java/org/sejongisc/backend/common/auth/config/RestTemplateConfig.java rename to backend/src/main/java/org/sejongisc/backend/common/config/db/RestTemplateConfig.java index b34b9754..db3ced53 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/config/RestTemplateConfig.java +++ b/backend/src/main/java/org/sejongisc/backend/common/config/db/RestTemplateConfig.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.common.auth.config; +package org.sejongisc.backend.common.config.db; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/backend/src/main/java/org/sejongisc/backend/common/config/StockDataSourceConfig.java b/backend/src/main/java/org/sejongisc/backend/common/config/db/StockDataSourceConfig.java similarity index 98% rename from backend/src/main/java/org/sejongisc/backend/common/config/StockDataSourceConfig.java rename to backend/src/main/java/org/sejongisc/backend/common/config/db/StockDataSourceConfig.java index 552b4d81..d46d05ff 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/config/StockDataSourceConfig.java +++ b/backend/src/main/java/org/sejongisc/backend/common/config/db/StockDataSourceConfig.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.common.config; +package org.sejongisc.backend.common.config.db; import jakarta.persistence.EntityManagerFactory; import org.springframework.beans.factory.annotation.Qualifier; diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/config/SecurityConfig.java b/backend/src/main/java/org/sejongisc/backend/common/config/security/SecurityConfig.java similarity index 67% rename from backend/src/main/java/org/sejongisc/backend/common/auth/config/SecurityConfig.java rename to backend/src/main/java/org/sejongisc/backend/common/config/security/SecurityConfig.java index 0f573b7a..343f3af3 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/config/SecurityConfig.java +++ b/backend/src/main/java/org/sejongisc/backend/common/config/security/SecurityConfig.java @@ -1,13 +1,17 @@ -package org.sejongisc.backend.common.auth.config; +package org.sejongisc.backend.common.config.security; import lombok.RequiredArgsConstructor; -import org.sejongisc.backend.common.auth.jwt.JwtAccessDeniedHandler; -import org.sejongisc.backend.common.auth.jwt.JwtAuthenticationEntryPoint; -import org.sejongisc.backend.common.auth.springsecurity.JwtAuthenticationFilter; +import org.sejongisc.backend.common.auth.service.oauth2.GithubServiceImpl; +import org.sejongisc.backend.common.exception.controller.JwtAccessDeniedHandler; +import org.sejongisc.backend.common.exception.controller.JwtAuthenticationEntryPoint; +import org.sejongisc.backend.common.auth.filter.JwtAuthenticationFilter; +import org.sejongisc.backend.user.entity.Role; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; import org.springframework.http.HttpMethod; +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; +import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -35,23 +39,29 @@ public class SecurityConfig { private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final JwtAccessDeniedHandler jwtAccessDeniedHandler; - private final CustomOAuth2UserService customOAuth2UserService; - private final CustomOidcUserService customOidcUserService; - private final OAuth2SuccessHandler oAuth2SuccessHandler; + private final GithubServiceImpl.CustomOAuth2UserService customOAuth2UserService; + private final GithubServiceImpl.CustomOidcUserService customOidcUserService; + private final GithubServiceImpl.OAuth2SuccessHandler oAuth2SuccessHandler; private final Environment env; - private boolean isProd() { - return List.of(env.getActiveProfiles()).contains("prod"); - } - private boolean isDev() { - return List.of(env.getActiveProfiles()).contains("dev"); - } - @Bean public AuthorizationRequestRepository authorizationRequestRepository() { return new HttpSessionOAuth2AuthorizationRequestRepository(); } + + // 계층적 권한 설정 + @Bean + public RoleHierarchy roleHierarchy() { + return RoleHierarchyImpl.withDefaultRolePrefix() + .role(Role.SYSTEM_ADMIN.name()).implies(Role.PRESIDENT.name()) + .role(Role.PRESIDENT.name()).implies(Role.VICE_PRESIDENT.name()) + .role(Role.VICE_PRESIDENT.name()).implies(Role.TEAM_LEADER.name()) + .role(Role.TEAM_LEADER.name()).implies(Role.TEAM_MEMBER.name()) + // PENDING_MEMBER는 계층에 포함시키지 않음 (접근 불가) + .build(); + } + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http @@ -86,38 +96,23 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti ) .authorizeHttpRequests(auth -> { - auth - .requestMatchers( - "/api/auth/signup", - "/api/auth/login", - "/api/auth/login/**", - "/actuator", - "/actuator/**", - "/api/auth/logout", - "/api/auth/reissue", - "/v3/api-docs/**", - "/swagger-ui/**", - "/api/user/id/find", - "/api/user/password/reset/**", - "/api/email/**", - "/swagger-resources/**", - "/webjars/**", - "/login/**", - "/oauth2/**" - ).permitAll(); - - auth.requestMatchers( - "/api/user/**", - "/api/user-bets/**" - ).authenticated(); - - auth.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() -// .anyRequest().authenticated(); - .anyRequest().permitAll(); + // 모두 접근 가능한 API + auth.requestMatchers(SecurityConstants.WHITELIST_URLS).permitAll(); + // 관리자 전용 API + auth.requestMatchers(SecurityConstants.ADMIN_ONLY_URLS).hasAnyRole(Role.PRESIDENT.name(), Role.SYSTEM_ADMIN.name()); + // 일반 서비스 API (정회원 이상만 접근 가능, PENDING_MEMBER 자동 차단) + // RoleHierarchy 덕분에 TEAM_MEMBER만 적어도 상위 직급은 다 통과됨 + auth.requestMatchers(SecurityConstants.MEMBER_ONLY_URLS).hasRole(Role.TEAM_MEMBER.name()); + + auth.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + + .anyRequest().authenticated(); + //.anyRequest().permitAll(); }) + //꼭 필요할 때만(OAuth 로그인 과정 등) 세션 생성 + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)); + // TODO : OAUTH2를 쿠키에 저장 시 OR OAUTH2 를 안쓸 시 STATELESS로 변경 고려 //.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); - .sessionManagement(session -> - session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)); if(jwtAuthenticationFilter != null) { http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); @@ -145,7 +140,12 @@ public CorsConfigurationSource corsConfigurationSource() { return source; } - + private boolean isProd() { + return List.of(env.getActiveProfiles()).contains("prod"); + } + private boolean isDev() { + return List.of(env.getActiveProfiles()).contains("dev"); + } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); diff --git a/backend/src/main/java/org/sejongisc/backend/common/config/security/SecurityConstants.java b/backend/src/main/java/org/sejongisc/backend/common/config/security/SecurityConstants.java new file mode 100644 index 00000000..565e8b4c --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/common/config/security/SecurityConstants.java @@ -0,0 +1,38 @@ +package org.sejongisc.backend.common.config.security; + +public class SecurityConstants { + public static final String[] WHITELIST_URLS = { + "/api/user/signup", + "/api/auth/login", + "/api/auth/login/**", + "/api/auth/logout", + "/api/auth/reissue", + "/api/user/password/reset/**", + "/api/email/**", + "/actuator/**", + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/swagger-resources/**", + "/webjars/**", + "/login/**", + "/oauth2/**", + "/favicon.ico", + "/error" + }; + + public static final String[] ADMIN_ONLY_URLS = { + "/api/admin/**" + }; + + // TODO : URL 추가 필요 + public static final String[] MEMBER_ONLY_URLS = { + "/api/user/**", + "/api/user-bets/**", + //"/api/board/**", + //"/api/backtest/**", + //"/api/quant-bot/**", + //"/api/attendance/**" + + }; +} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/config/OpenApiConfig.java b/backend/src/main/java/org/sejongisc/backend/common/config/swagger/OpenApiConfig.java similarity index 90% rename from backend/src/main/java/org/sejongisc/backend/common/auth/config/OpenApiConfig.java rename to backend/src/main/java/org/sejongisc/backend/common/config/swagger/OpenApiConfig.java index 7b90fee2..068b8116 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/config/OpenApiConfig.java +++ b/backend/src/main/java/org/sejongisc/backend/common/config/swagger/OpenApiConfig.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.common.auth.config; +package org.sejongisc.backend.common.config.swagger; import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.servers.Server; diff --git a/backend/src/main/java/org/sejongisc/backend/common/config/SwaggerConfig.java b/backend/src/main/java/org/sejongisc/backend/common/config/swagger/SwaggerConfig.java similarity index 96% rename from backend/src/main/java/org/sejongisc/backend/common/config/SwaggerConfig.java rename to backend/src/main/java/org/sejongisc/backend/common/config/swagger/SwaggerConfig.java index fe289edc..99a71c8d 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/config/SwaggerConfig.java +++ b/backend/src/main/java/org/sejongisc/backend/common/config/swagger/SwaggerConfig.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.common.config; +package org.sejongisc.backend.common.config.swagger; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; diff --git a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java index 49ab2216..538b09a4 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java +++ b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java @@ -54,6 +54,8 @@ public enum ErrorCode { MISSING_AUTH_TOKEN(HttpStatus.UNAUTHORIZED, "인증 토큰이 필요합니다."), + MISSING_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 필요합니다."), + INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 엑세스 토큰입니다."), // EMAIL diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/jwt/JwtAccessDeniedHandler.java b/backend/src/main/java/org/sejongisc/backend/common/exception/controller/JwtAccessDeniedHandler.java similarity index 95% rename from backend/src/main/java/org/sejongisc/backend/common/auth/jwt/JwtAccessDeniedHandler.java rename to backend/src/main/java/org/sejongisc/backend/common/exception/controller/JwtAccessDeniedHandler.java index a6730bae..1ecaabe4 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/jwt/JwtAccessDeniedHandler.java +++ b/backend/src/main/java/org/sejongisc/backend/common/exception/controller/JwtAccessDeniedHandler.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.common.auth.jwt; +package org.sejongisc.backend.common.exception.controller; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/jwt/JwtAuthenticationEntryPoint.java b/backend/src/main/java/org/sejongisc/backend/common/exception/controller/JwtAuthenticationEntryPoint.java similarity index 95% rename from backend/src/main/java/org/sejongisc/backend/common/auth/jwt/JwtAuthenticationEntryPoint.java rename to backend/src/main/java/org/sejongisc/backend/common/exception/controller/JwtAuthenticationEntryPoint.java index c148fc54..4c2bfa6d 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/jwt/JwtAuthenticationEntryPoint.java +++ b/backend/src/main/java/org/sejongisc/backend/common/exception/controller/JwtAuthenticationEntryPoint.java @@ -1,4 +1,4 @@ -package org.sejongisc.backend.common.auth.jwt; +package org.sejongisc.backend.common.exception.controller; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.ServletException; diff --git a/backend/src/main/java/org/sejongisc/backend/point/controller/PointHistoryController.java b/backend/src/main/java/org/sejongisc/backend/point/controller/PointHistoryController.java index c8fb0b48..e09f9c5e 100644 --- a/backend/src/main/java/org/sejongisc/backend/point/controller/PointHistoryController.java +++ b/backend/src/main/java/org/sejongisc/backend/point/controller/PointHistoryController.java @@ -4,7 +4,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; +import org.sejongisc.backend.common.auth.dto.CustomUserDetails; import org.sejongisc.backend.point.dto.PointHistoryResponse; import org.sejongisc.backend.point.service.PointHistoryService; import org.springframework.data.domain.PageRequest; diff --git a/backend/src/main/java/org/sejongisc/backend/template/controller/TemplateController.java b/backend/src/main/java/org/sejongisc/backend/template/controller/TemplateController.java index cdeca16a..46ecd842 100644 --- a/backend/src/main/java/org/sejongisc/backend/template/controller/TemplateController.java +++ b/backend/src/main/java/org/sejongisc/backend/template/controller/TemplateController.java @@ -3,7 +3,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; +import org.sejongisc.backend.common.auth.dto.CustomUserDetails; import org.sejongisc.backend.template.dto.TemplateRequest; import org.sejongisc.backend.template.dto.TemplateResponse; import org.sejongisc.backend.template.service.TemplateService; @@ -11,7 +11,6 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import java.util.List; import java.util.UUID; diff --git a/backend/src/main/java/org/sejongisc/backend/template/service/TemplateService.java b/backend/src/main/java/org/sejongisc/backend/template/service/TemplateService.java index 66d10e5f..5000bddd 100644 --- a/backend/src/main/java/org/sejongisc/backend/template/service/TemplateService.java +++ b/backend/src/main/java/org/sejongisc/backend/template/service/TemplateService.java @@ -1,7 +1,6 @@ package org.sejongisc.backend.template.service; -import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.sejongisc.backend.backtest.repository.BacktestRunRepository; @@ -11,7 +10,7 @@ import org.sejongisc.backend.template.dto.TemplateResponse; import org.sejongisc.backend.template.entity.Template; import org.sejongisc.backend.template.repository.TemplateRepository; -import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.user.repository.UserRepository; import org.sejongisc.backend.user.entity.User; import org.springframework.stereotype.Service; diff --git a/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java b/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java index 1ba9ee81..7c1616f3 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java +++ b/backend/src/main/java/org/sejongisc/backend/user/controller/UserController.java @@ -1,30 +1,25 @@ package org.sejongisc.backend.user.controller; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; -import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; -import org.sejongisc.backend.auth.dto.SignupRequest; -import org.sejongisc.backend.auth.dto.SignupResponse; +import org.sejongisc.backend.common.auth.controller.AuthCookieHelper; +import org.sejongisc.backend.common.auth.dto.CustomUserDetails; +import org.sejongisc.backend.common.auth.dto.SignupRequest; +import org.sejongisc.backend.common.auth.dto.SignupResponse; +import org.sejongisc.backend.common.auth.service.RefreshTokenService; import org.sejongisc.backend.user.dto.*; import org.sejongisc.backend.user.service.UserService; -import org.sejongisc.backend.user.service.projection.UserIdNameProjection; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; 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.*; -import java.util.List; import java.util.Map; -import java.util.UUID; @RestController @RequiredArgsConstructor @@ -33,306 +28,53 @@ @Tag(name = "사용자 API", description = "회원 정보 조회 및 수정 관련 API") public class UserController { - private final UserService userService; - - @Operation( - summary = "내 정보 조회 API", - description = "로그인된 사용자의 정보를 조회합니다. Access Token이 필요합니다.", - responses = { - @ApiResponse( - responseCode = "200", - description = "조회 성공", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = UserInfoResponse.class), - examples = @ExampleObject(value = """ - { - "userId": "9f6d0e22-45f1-4e5e-bc94-f1f6e7d28b44", - "name": "홍길동", - "email": "testuser@example.com", - "phoneNumber": "01012345678", - "point": 1500, - "role": "USER", - "authorities": ["ROLE_USER"] - } - """) - ) - ), - @ApiResponse( - responseCode = "401", - description = "인증되지 않은 사용자", - content = @Content(mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "message": "인증이 필요합니다." - } - """)) - ) - } - ) - @GetMapping("/details") - public ResponseEntity getUserInfo(@AuthenticationPrincipal CustomUserDetails user) { - if (user == null) { - log.warn("인증되지 않은 사용자 접근 시도"); - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(Map.of("message", "인증이 필요합니다.")); - } - - log.info("email: {} 권한: {}", user.getUsername(), user.getAuthorities()); - - UserInfoResponse response = new UserInfoResponse( - user.getUserId(), - user.getName(), - user.getEmail(), - user.getPhoneNumber(), - user.getPoint(), - user.getRole().name(), - user.getAuthorities() - ); - - return ResponseEntity.ok(response); - } - - @Operation( - summary = "회원 정보 수정 API", - description = "회원 정보를 수정합니다. 인증된 사용자만 이용 가능하며 본인 정보만 수정할 수 있습니다.", - responses = { - @ApiResponse( - responseCode = "200", - description = "수정 성공", - content = @Content(mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "message": "회원 정보가 수정되었습니다." - } - """)) - ), - @ApiResponse( - responseCode = "401", - description = "인증되지 않은 사용자", - content = @Content(mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "message": "인증 정보가 필요합니다." - } - """)) - ), - @ApiResponse( - responseCode = "403", - description = "본인 이외의 정보 수정 시도", - content = @Content(mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "message": "본인의 정보만 수정할 수 있습니다." - } - """)) - ) - } - ) - @PatchMapping("/{userId}") - public ResponseEntity updateUser( - @PathVariable UUID userId, - @RequestBody @Valid UserUpdateRequest request, - @AuthenticationPrincipal CustomUserDetails authenticatedUser - ) { -// if(authenticatedUser == null){ -// return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "인증 정보가 필요합니다.")); -// } - - log.info("인증된 사용자 ID={}, 요청한 userId={}", authenticatedUser.getUserId(), userId); - - // 본인 허용 - if (!authenticatedUser.getUserId().equals(userId)) { - return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(Map.of("message", "본인의 정보만 수정할 수 있습니다.")); - } - - userService.updateUser(userId, request); - return ResponseEntity.ok("회원 정보가 수정되었습니다."); - } - - @Operation( - summary = "아이디 찾기 API", - description = """ - 사용자의 이름과 전화번호를 입력하면 가입된 이메일 주소를 반환합니다. - - 이름(name)과 전화번호(phoneNumber)가 모두 일치하는 회원만 조회됩니다. - - 일치하는 회원이 없을 경우 404 응답을 반환합니다. - """, - responses = { - @ApiResponse( - responseCode = "200", - description = "조회 성공", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "email": "testuser@example.com" - } - """) - ) - ), - @ApiResponse( - responseCode = "404", - description = "일치하는 회원 없음", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "message": "해당 정보로 가입된 사용자를 찾을 수 없습니다." - } - """) - ) - ) - } - ) - @PostMapping("/id/find") - public ResponseEntity findUserID(@RequestBody @Valid UserIdFindRequest request) { - String name = request.name(); - String phone = request.phoneNumber(); - String email = userService.findEmailByNameAndPhone(name, phone); - - if (email == null) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(Map.of("message", "해당 정보로 가입된 사용자를 찾을 수 없습니다.")); - } - - return ResponseEntity.ok(Map.of("email", email)); - } - - @Operation( - summary = "비밀번호 재설정: 인증코드 발송 API", - description = """ - 가입된 이메일 주소로 비밀번호 재설정을 위한 인증코드를 전송합니다. - - 인증코드는 3분간 유효합니다. - - 존재하지 않는 이메일일 경우 404 에러를 반환합니다. - """, - responses = { - @ApiResponse( - responseCode = "200", - description = "인증코드 발송 성공", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "message": "인증코드를 전송했습니다." - } - """) - ) - ), - @ApiResponse( - responseCode = "404", - description = "이메일 미존재", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "message": "해당 이메일로 가입된 사용자를 찾을 수 없습니다." - } - """) - ) - ) - } - ) - @PostMapping("/password/reset/send") - public ResponseEntity sendReset(@RequestBody @Valid PasswordResetSendRequest req){ - String email = req.email().trim(); - log.info("비밀번호 재설정 요청"); // 개인정보 로그 남기지 않기 - userService.passwordReset(email); - return ResponseEntity.ok(Map.of("message", "인증코드를 전송했습니다.")); - } - - @Operation( - summary = "비밀번호 재설정: 인증코드 검증 API", - description = """ - 이메일과 인증코드를 검증하고, 유효한 경우 비밀번호 재설정용 토큰(`resetToken`)을 발급합니다. - - 인증코드는 3분간만 유효합니다. - - 검증에 성공하면 resetToken(10분 유효)을 반환합니다. - """, - responses = { - @ApiResponse( - responseCode = "200", - description = "검증 성공 및 resetToken 발급", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "resetToken": "c8a2434d-7e11-4f7e-a201-b9fbc9d7d43a" - } - """) - ) - ), - @ApiResponse( - responseCode = "400", - description = "잘못된 코드 또는 만료된 코드", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "message": "인증코드가 올바르지 않거나 만료되었습니다." - } - """) - ) - ) - } - ) - @PostMapping("/password/reset/verify") - public ResponseEntity verifyReset(@RequestBody @Valid PasswordResetVerifyRequest req){ - String email = req.email().trim(); - String code = req.code().trim(); - - String token = userService.verifyResetCodeAndIssueToken(email, code); - return ResponseEntity.ok(Map.of("resetToken", token)); - } - - @Operation( - summary = "비밀번호 재설정 최종 API", - description = """ - 검증된 resetToken과 새 비밀번호를 전달하면 비밀번호를 최종 변경합니다. - - resetToken은 10분간 유효합니다. - - 비밀번호 정책: - • 길이: 8~20자 - • 최소 1개의 대문자(A-Z) - • 최소 1개의 소문자(a-z) - • 최소 1개의 숫자(0-9) - • 최소 1개의 특수문자(!@#$%^&*()_+=-{};:'",.<>/?) - - 위 조건을 만족하지 않으면 400 응답을 반환합니다. - - 변경 완료 후, 로그인 화면으로 이동하여 새 비밀번호로 로그인할 수 있습니다. - """, - responses = { - @ApiResponse( - responseCode = "200", - description = "비밀번호 변경 성공", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "message": "비밀번호가 변경되었습니다. 다시 로그인해 주세요." - } - """) - ) - ), - @ApiResponse( - responseCode = "400", - description = "비밀번호 정책 위반 또는 잘못된/만료된 토큰", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "message": "비밀번호는 8~20자, 대소문자/숫자/특수문자를 모두 포함해야 합니다." - } - """) - ) - ) - } - ) - @PostMapping("/password/reset/commit") - public ResponseEntity commitReset(@RequestBody @Valid PasswordResetCommitRequest req){ - userService.resetPasswordByToken(req.resetToken(), req.newPassword()); - return ResponseEntity.ok(Map.of("message", "비밀번호가 변경되었습니다. 다시 로그인해 주세요.")); - } - - - - - + private final UserService userService; + private final AuthCookieHelper authCookieHelper; + + @Operation(summary = "회원 가입", description = "회장이 승인하기 전까지 PENDING 상태가 유지되며, 웹사이트를 사용할 수 없습니다.") + @ApiResponse(responseCode = "201", description = "회원가입 성공") + @PostMapping("/signup") + public ResponseEntity signup(@Valid @RequestBody SignupRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(userService.signup(request)); + } + + @Operation(summary = "회원 탈퇴", description = "UserStatus.OUT 으로 변경하여 softDelete 처리 후, 리프레시 토큰을 삭제합니다.") + @DeleteMapping("/withdraw") + public ResponseEntity withdraw(@AuthenticationPrincipal CustomUserDetails user) { + userService.deleteUserSoftDelete(user.getUserId()); + return ResponseEntity.noContent() + .header(HttpHeaders.SET_COOKIE, authCookieHelper.deleteCookie("refresh").toString()) + .build(); + } + + @Operation(summary = "내 정보 조회") + @GetMapping("/details") + public ResponseEntity getUserInfo(@AuthenticationPrincipal CustomUserDetails user) { + return ResponseEntity.ok(new UserInfoResponse(user.getUserId(), user.getName(), user.getEmail(), user.getPhoneNumber(), user.getPoint(), user.getRole().name(), user.getAuthorities())); + } + + @Operation(summary = "내 정보 수정") + @PatchMapping("/details") + public ResponseEntity updateUser(@RequestBody @Valid UserUpdateRequest request, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { + userService.updateUser(customUserDetails.getUserId(), request); + return ResponseEntity.ok().build(); + } + + /* + @Operation(summary = "아이디 찾기") + @PostMapping("/id/find") + public ResponseEntity findUserID(@RequestBody @Valid UserIdFindRequest request) { + String email = userService.findEmailByNameAndPhone(request.name(), request.phoneNumber()); + return ResponseEntity.ok(Map.of("email", email)); + } + */ + + // TODO : 비밀번호 재설정 시 학번 입력 고려 + @Operation(summary = "비밀번호 재설정 : 이메일로 인증코드를 전송합니다.") + @PostMapping("/password/reset/send") + public ResponseEntity sendReset(@RequestBody @Valid PasswordResetSendRequest req){ + userService.passwordReset(req.email().trim()); + return ResponseEntity.ok(Map.of("message", "인증코드를 전송했습니다.")); + } } \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/user/dto/UserInfoResponse.java b/backend/src/main/java/org/sejongisc/backend/user/dto/UserInfoResponse.java index 262631e3..7ed65b8b 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/dto/UserInfoResponse.java +++ b/backend/src/main/java/org/sejongisc/backend/user/dto/UserInfoResponse.java @@ -5,7 +5,7 @@ import java.util.UUID; import lombok.AllArgsConstructor; import lombok.Getter; -import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; +import org.sejongisc.backend.common.auth.dto.CustomUserDetails; import org.sejongisc.backend.user.entity.User; @Getter diff --git a/backend/src/main/java/org/sejongisc/backend/user/dto/UserUpdateRequest.java b/backend/src/main/java/org/sejongisc/backend/user/dto/UserUpdateRequest.java index 964067d2..45d3bf8e 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/dto/UserUpdateRequest.java +++ b/backend/src/main/java/org/sejongisc/backend/user/dto/UserUpdateRequest.java @@ -1,7 +1,8 @@ package org.sejongisc.backend.user.dto; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import lombok.Getter; import lombok.Setter; @@ -10,10 +11,10 @@ @Setter @Schema( name = "UserUpdateRequest", - description = "회원정보 수정 요청 객체 (이름, 전화번호, 비밀번호 중 수정할 항목만 입력)" + description = "회원정보 수정 시, 이메일/비밀번호 중 수정할 항목만 입력" ) public class UserUpdateRequest { - +/* @Schema( description = "변경할 이름 (선택 입력)", example = "홍길동" @@ -30,11 +31,22 @@ public class UserUpdateRequest { message = "전화번호는 숫자만 10~11자리로 입력해주세요." ) private String phoneNumber; + */ + @Email(message = "유효한 이메일 형식이 아닙니다.") + @Schema(description = "비밀번호 재설정용 이메일", example = "sira@sejong.ac.kr") + private String email; + + @Schema( + description = "기존 비밀번호 (변경 시에만 포함)", + example = "password123!" + ) + @Size(min = 8, message = "비밀번호는 최소 8자 이상 입력해야 합니다.") + private String currentPassword; @Schema( - description = "변경할 비밀번호 (선택 입력, 변경 시에만 포함)", + description = "변경할 비밀번호 (변경 시에만 포함)", example = "Newpassword123!" ) @Size(min = 8, message = "비밀번호는 최소 8자 이상 입력해야 합니다.") - private String password; + private String newPassword; } diff --git a/backend/src/main/java/org/sejongisc/backend/user/entity/Gender.java b/backend/src/main/java/org/sejongisc/backend/user/entity/Gender.java new file mode 100644 index 00000000..5c1da2ee --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/user/entity/Gender.java @@ -0,0 +1,6 @@ +package org.sejongisc.backend.user.entity; + +public enum Gender { + MALE, + FEMALE +} diff --git a/backend/src/main/java/org/sejongisc/backend/user/entity/Role.java b/backend/src/main/java/org/sejongisc/backend/user/entity/Role.java index 047674de..590b5a20 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/entity/Role.java +++ b/backend/src/main/java/org/sejongisc/backend/user/entity/Role.java @@ -1,8 +1,13 @@ package org.sejongisc.backend.user.entity; +// 일반 회원가입 : 회장 승인이 있어야만 설정 가능 +// 엑셀 회원가입 : 회장 승인 없이 설정 가능 (user.isManagerPosition 으로 판단) public enum Role { - PRESIDENT, // 회장 - VICE_PRESIDENT, // 부회장 - TEAM_LEADER, // 팀장 - TEAM_MEMBER // 부원 + SYSTEM_ADMIN, // 시스템 관리자 + PRESIDENT, // 회장 + VICE_PRESIDENT, // 부회장 + TEAM_LEADER, // 팀장 + TEAM_MEMBER, // 부원 + PENDING_MEMBER; // 대기회원 (회장이 승인 전 상태) + // 추가 가능 : SENIOR (선배/OB): 게시물 열람 위주 (포인트 활동 등은 제한 가능) } \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/user/entity/User.java b/backend/src/main/java/org/sejongisc/backend/user/entity/User.java index ca9cf537..446b2601 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/entity/User.java +++ b/backend/src/main/java/org/sejongisc/backend/user/entity/User.java @@ -4,7 +4,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import jakarta.persistence.*; import lombok.*; -import org.sejongisc.backend.auth.entity.UserOauthAccount; +import org.sejongisc.backend.common.auth.dto.SignupRequest; +import org.sejongisc.backend.common.auth.entity.UserOauthAccount; import org.sejongisc.backend.common.entity.postgres.BasePostgresEntity; import java.util.ArrayList; @@ -26,9 +27,9 @@ public class User extends BasePostgresEntity{ @Column(name = "user_id", columnDefinition = "uuid") private UUID userId; - //OAuth 전용 계정 대비 nullable 허용 가능 - @Column(columnDefinition = "citext", unique = true, nullable = true) - private String email; + // 로그인 시 이메일이 아닌 학번 입력 + @Column(name = "student_id", unique = true, nullable = false) + private String studentId; // 학번: 엑셀 매칭 및 계정 식별의 핵심 키 @Column(name = "password_hash") private String passwordHash; @@ -39,10 +40,34 @@ public class User extends BasePostgresEntity{ @Column(name = "phone_number") private String phoneNumber; + // --- 엑셀 장부 기반 추가 데이터 --- + private String college; // 단과대학 + private String department; // 학과 + private Integer generation; // 기수 (처음 활동한 연도 기준) + private String teamName; // 활동팀 (예: 매크로팀, 리서치팀) + + @Enumerated(EnumType.STRING) + private Gender gender; // 성별 + + @Column(name = "is_new_member", nullable = false) + private boolean isNewMember; // 신규 여부 (포인트나 이벤트 대상자 선정용) + @Enumerated(EnumType.STRING) @Column(nullable = false) private Role role; + @Column(name = "position_name") // 엑셀의 '직위' 컬럼 데이터 그대로 저장 + private String positionName; + + //OAuth 전용 계정 대비 nullable 허용 가능 + @Column(columnDefinition = "citext", unique = true, nullable = true) + private String email; // 추후 비밀번호 찾기용 및 공지 발송용 + + @Enumerated(EnumType.STRING) // 새 장부 업로드 시: 기존에 ACTIVE한 모든 인원을 INACTIVE로 일괄 업데이트 + @Column(nullable = false) // 새 엑셀에 있는 studentId을 대조하여, 명단에 있는 사람만 다시 ACTIVE로 바꾸고 + @Builder.Default // generation(기수)과 positionName(직위)을 최신화 + private UserStatus status = UserStatus.ACTIVE; // 활동 상태 (ACTIVE, INACTIVE, GRADUATED, OUT 등) + @Column(columnDefinition = "integer default 0",nullable = false) private Integer point; @@ -56,11 +81,20 @@ public class User extends BasePostgresEntity{ @JsonIgnore private List oauthAccounts = new ArrayList<>(); + // 권한 확인용 편의 메서드 + public boolean isManagerPosition() { + if (this.positionName == null) return false; + // 직위에 '팀장', '대표', '부대표' 등의 키워드가 있으면 운영진 권한 부여 후보 + return this.positionName.contains("팀장") || + this.positionName.contains("대표") || + this.positionName.contains("회장"); + } + // 기본값 지정 @PrePersist public void prePersist() { if (this.role == null) { - this.role = Role.TEAM_MEMBER; + this.role = Role.PENDING_MEMBER; } if (this.point == null) { this.point = 0; @@ -69,4 +103,23 @@ public void prePersist() { public void updatePoint(int amount) { this.point += amount; } + + public static User createUserWithSignupAndPending(SignupRequest request, String encodedPw) { + return User.builder() + .role(Role.TEAM_MEMBER) // TODO : 운영진 승인 로직 추가 후 PENDING_MEMBER로 변경 필요 + .studentId(request.getStudentId()) + .name(request.getName()) + .passwordHash(encodedPw) + .phoneNumber(request.getPhoneNumber()) + .email(request.getEmail()) + .gender(request.getGender()) + .college(request.getCollege()) // 단과대 + .department(request.getDepartment()) // 학과 + .generation(request.getGeneration()) // + .teamName(request.getTeamName()) // 소속 팀명 + .isNewMember(true) // 신규 가입자 + .point(0) + .status(UserStatus.ACTIVE) // 기본 활동 상태 + .build(); + } } diff --git a/backend/src/main/java/org/sejongisc/backend/user/entity/UserStatus.java b/backend/src/main/java/org/sejongisc/backend/user/entity/UserStatus.java new file mode 100644 index 00000000..65a8d51c --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/user/entity/UserStatus.java @@ -0,0 +1,17 @@ +package org.sejongisc.backend.user.entity; + + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum UserStatus { + ACTIVE("활동 중"), + INACTIVE("활동 중지"), + GRADUATED("졸업생"), + OUT("탈퇴"), + PENDING("승인 대기"); + + private final String description; // 한글 명칭 +} diff --git a/backend/src/main/java/org/sejongisc/backend/user/dao/UserRepository.java b/backend/src/main/java/org/sejongisc/backend/user/repository/UserRepository.java similarity index 59% rename from backend/src/main/java/org/sejongisc/backend/user/dao/UserRepository.java rename to backend/src/main/java/org/sejongisc/backend/user/repository/UserRepository.java index 79bd620a..56e366a6 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/dao/UserRepository.java +++ b/backend/src/main/java/org/sejongisc/backend/user/repository/UserRepository.java @@ -1,7 +1,7 @@ -package org.sejongisc.backend.user.dao; +package org.sejongisc.backend.user.repository; + import org.sejongisc.backend.user.entity.User; -import org.sejongisc.backend.user.service.projection.UserIdNameProjection; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -11,26 +11,17 @@ public interface UserRepository extends JpaRepository { boolean existsByEmail(String email); - boolean existsByPhoneNumber(String phoneNumber); + boolean existsByEmailOrStudentId(String email, String studentId); + boolean existsByStudentId(String studentId); Optional findUserByEmail(String email); - List findAllByOrderByPointDesc(); - - Optional findByNameAndPhoneNumber(String name, String phoneNumber); - - @Query(""" - select u.userId as userId, - u.name as name, - u.email as email - from User u - """) - List findAllUserIdAndName(); - @Query( "SELECT u FROM User u " + "LEFT JOIN Account a ON u.userId = a.ownerId " + "WHERE a.accountId IS NULL") List findAllUsersMissingAccount(); + + Optional findByStudentId(String studentId); } diff --git a/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java b/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java index 03bd6b7a..7fff227b 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java +++ b/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java @@ -1,37 +1,245 @@ package org.sejongisc.backend.user.service; -import org.sejongisc.backend.auth.dto.SignupRequest; -import org.sejongisc.backend.auth.dto.SignupResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sejongisc.backend.common.auth.dto.SignupRequest; +import org.sejongisc.backend.common.auth.dto.SignupResponse; +import org.sejongisc.backend.common.auth.service.EmailService; +import org.sejongisc.backend.common.auth.service.RefreshTokenService; +import org.sejongisc.backend.common.annotation.OptimisticRetry; +import org.sejongisc.backend.common.exception.CustomException; +import org.sejongisc.backend.common.exception.ErrorCode; +import org.sejongisc.backend.point.dto.AccountEntry; +import org.sejongisc.backend.point.entity.Account; +import org.sejongisc.backend.point.entity.AccountName; +import org.sejongisc.backend.point.entity.TransactionReason; +import org.sejongisc.backend.point.service.AccountService; +import org.sejongisc.backend.point.service.PointLedgerService; import org.sejongisc.backend.user.dto.UserUpdateRequest; import org.sejongisc.backend.user.entity.User; -import org.sejongisc.backend.auth.oauth.OauthUserInfo; -import org.sejongisc.backend.user.service.projection.UserIdNameProjection; +import org.sejongisc.backend.user.entity.UserStatus; +import org.sejongisc.backend.user.repository.UserRepository; +import org.sejongisc.backend.user.util.PasswordPolicyValidator; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.Duration; import java.util.List; import java.util.UUID; -public interface UserService { - SignupResponse signUp(SignupRequest dto); +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { - User findOrCreateUser(OauthUserInfo oauthInfo); + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final EmailService emailService; + private final RedisTemplate redisTemplate; + private final RefreshTokenService refreshTokenService; + private final AccountService accountService; + private final PointLedgerService pointLedgerService; - void updateUser(UUID userId, UserUpdateRequest request); + // --- 핵심 회원 서비스 --- - User getUserById(UUID userId); + @Transactional + @OptimisticRetry + public SignupResponse signup(SignupRequest request) { + if (userRepository.existsByEmailOrStudentId(request.getEmail(), request.getStudentId())) { + if (userRepository.existsByStudentId(request.getStudentId())) throw new CustomException(ErrorCode.DUPLICATE_USER); + throw new CustomException(ErrorCode.DUPLICATE_PHONE); + } + String trimmedPassword = PasswordPolicyValidator.getValidatedPassword(request.getPassword()); + String encodedPw = passwordEncoder.encode(trimmedPassword); + User user = User.createUserWithSignupAndPending(request, encodedPw); - void deleteUserWithOauth(UUID userId); + try { + User saved = userRepository.save(user); + Account userAccount = accountService.createUserAccount(user.getUserId()); + pointLedgerService.processTransaction( + TransactionReason.SIGNUP_REWARD, + user.getUserId(), + AccountEntry.credit(accountService.getAccountByName(AccountName.SYSTEM_ISSUANCE), 100L), + AccountEntry.debit(userAccount, 100L) + ); + log.info("포인트 계정 생성 및 초기 포인트 지급 완료: {}", user.getEmail()); + return SignupResponse.from(saved); + } catch (DataIntegrityViolationException e) { + throw new CustomException(ErrorCode.DUPLICATE_USER); + } + } - String findEmailByNameAndPhone(String name, String phoneNumber); + @Transactional + public void updateUser(UUID userId, UserUpdateRequest request) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + if (request.getEmail() != null) { + user.setEmail(request.getEmail().trim()); + } - void passwordReset(String email); + // 비밀번호 변경 로직 (새 비밀번호가 입력된 경우에만 실행) + if (request.getNewPassword() != null && !request.getNewPassword().isBlank()) { + if (!passwordEncoder.matches(request.getCurrentPassword(), user.getPasswordHash())) { + throw new CustomException(ErrorCode.INVALID_INPUT); // 비밀번호 불일치 에러 + } + // 새 비밀번호 정제 및 정책 검증 + String cleanNewPassword = PasswordPolicyValidator.getValidatedPassword(request.getNewPassword()); - String verifyResetCodeAndIssueToken(String email, String code); + // 새 비밀번호 인코딩 및 설정 + user.setPasswordHash(passwordEncoder.encode(cleanNewPassword)); - void resetPasswordByToken(String resetToken, String newPassword); + // 비밀번호 변경 시 모든 기기 로그아웃 처리 (선택 사항) + refreshTokenService.deleteByUserId(user.getUserId()); + } + log.info("회원 정보 수정 완료: userId={}", userId); + } - User upsertOAuthUser(String provider, String providerId, String email, String name); + public void passwordReset(String email) { + String nEmail = validateNotBlank(email, "이메일"); - List getUserProjectionList(); + if (!userRepository.existsByEmail(nEmail)) { + log.debug("존재하지 않는 이메일로 비밀번호 재설정 요청: {}", nEmail); + return; + } - List findAllUsersMissingAccount(); -} + emailService.sendResetEmail(nEmail); + } + + public String verifyResetCodeAndIssueToken(String email, String code) { + String nEmail = validateNotBlank(email, "이메일"); + String nCode = validateNotBlank(code, "인증코드"); + + emailService.verifyResetEmail(nEmail, nCode); + + String token = UUID.randomUUID().toString(); + saveResetTokenToRedis(token, nEmail); + + return token; + } + + @Transactional + public void resetPasswordByToken(String resetToken, String newPassword) { + String email = getEmailFromRedis(resetToken); + User user = userRepository.findUserByEmail(email) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + String trimmedPassword = PasswordPolicyValidator.getValidatedPassword(newPassword); + + user.setPasswordHash(passwordEncoder.encode(trimmedPassword)); + + deleteResetTokenFromRedis(resetToken); + refreshTokenService.deleteByUserId(user.getUserId()); + } + + public List findAllUsersMissingAccount() { + return userRepository.findAllUsersMissingAccount(); + } + + // --- 내부 헬퍼 메서드 --- + + private String validateNotBlank(String value, String fieldName) { + if (value == null || value.trim().isEmpty()) { + throw new CustomException(ErrorCode.INVALID_INPUT); + } + return value.trim(); + } + + // TODO : RedisService로 분리 고려 + private void saveResetTokenToRedis(String token, String email) { + try { + redisTemplate.opsForValue().set("PASSWORD_RESET:" + token, email, Duration.ofMinutes(10)); + } catch (Exception e) { + log.error("Redis 저장 실패", e); + throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + private String getEmailFromRedis(String token) { + try { + String email = (String) redisTemplate.opsForValue().get("PASSWORD_RESET:" + token); + if (email == null) throw new CustomException(ErrorCode.EMAIL_CODE_NOT_FOUND); + return email; + } catch (Exception e) { + log.error("Redis 조회 실패", e); + throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + private void deleteResetTokenFromRedis(String token) { + try { + redisTemplate.delete("PASSWORD_RESET:" + token); + } catch (Exception e) { + log.error("Redis 삭제 실패", e); + } + } + + @Transactional + public void deleteUserSoftDelete(UUID userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + user.setStatus(UserStatus.OUT); + refreshTokenService.deleteByUserId(userId); + log.info("회원 softdelete 처리 완료: userId={}", userId); + } + + // ------------------------ (비활성화) OAuth2 관련 로직 ------------------------ + + /* + @Transactional + public User upsertOAuthUser(String provider, String providerUid, String email, String name) { + AuthProvider authProvider = AuthProvider.valueOf(provider.toUpperCase()); + return oauthAccountRepository.findByProviderAndProviderUid(authProvider, providerUid) + .map(UserOauthAccount::getUser) + .orElseGet(() -> { + User savedUser = userRepository.save(User.builder().email(email).name(name).role(Role.TEAM_MEMBER).build()); + oauthAccountRepository.save(UserOauthAccount.builder().user(savedUser).provider(authProvider).providerUid(providerUid).build()); + return savedUser; + }); + } + + // 기존 findOrCreateUser는 upsertOAuthUser와 로직이 겹치므로 통합 권장하나, 유지 시 하단에 배치 + @Transactional + @OptimisticRetry + public User findOrCreateUser(OauthUserInfo oauthInfo) { + return oauthAccountRepository.findByProviderAndProviderUid(oauthInfo.getProvider(), oauthInfo.getProviderUid()) + .map(UserOauthAccount::getUser) + .orElseGet(() -> { + User savedUser = userRepository.save(User.builder().name(oauthInfo.getName()).role(Role.TEAM_MEMBER).build()); + Account userAccount = accountService.createUserAccount(savedUser.getUserId()); + pointLedgerService.processTransaction( + TransactionReason.SIGNUP_REWARD, + savedUser.getUserId(), + AccountEntry.credit(accountService.getAccountByName(AccountName.SYSTEM_ISSUANCE), 100L), + AccountEntry.debit(userAccount, 100L) + ); + log.info("포인트 계정 생성 및 초기 포인트 지급 완료: {}", savedUser.getEmail()); + oauthAccountRepository.save(UserOauthAccount.builder() + .user(savedUser).provider(oauthInfo.getProvider()).providerUid(oauthInfo.getProviderUid()) + .accessToken(tokenEncryptor.encrypt(oauthInfo.getAccessToken())).build()); + return savedUser; + }); + } + + @Transactional + public void deleteUserWithOauth(UUID userId) { + User user = findUserById(userId); + user.getOauthAccounts().forEach(account -> { + String provider = account.getProvider().name().toLowerCase(); + String accessToken = tokenEncryptor.decrypt(account.getAccessToken()); + switch (provider) { + case "kakao" -> oauthUnlinkService.unlinkKakao(accessToken); + case "google" -> oauthUnlinkService.unlinkGoogle(accessToken); + case "github" -> oauthUnlinkService.unlinkGithub(accessToken); + default -> log.warn("지원하지 않는 소셜 서비스: {}", provider); + } + }); + + userRepository.delete(user); + log.info("회원 탈퇴 완료: userId={}", userId); + } + */ +} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/user/service/UserServiceImpl.java b/backend/src/main/java/org/sejongisc/backend/user/service/UserServiceImpl.java deleted file mode 100644 index 1356784b..00000000 --- a/backend/src/main/java/org/sejongisc/backend/user/service/UserServiceImpl.java +++ /dev/null @@ -1,397 +0,0 @@ -package org.sejongisc.backend.user.service; - - -import org.sejongisc.backend.auth.entity.AuthProvider; -import org.sejongisc.backend.auth.service.EmailService; -import org.sejongisc.backend.auth.service.OauthUnlinkService; -import org.sejongisc.backend.auth.service.RefreshTokenService; -import org.sejongisc.backend.common.annotation.OptimisticRetry; -import org.sejongisc.backend.common.auth.jwt.TokenEncryptor; -import org.sejongisc.backend.point.dto.AccountEntry; -import org.sejongisc.backend.point.entity.Account; -import org.sejongisc.backend.point.entity.AccountName; -import org.sejongisc.backend.point.entity.TransactionReason; -import org.sejongisc.backend.point.service.AccountService; -import org.sejongisc.backend.point.service.PointLedgerService; -import org.sejongisc.backend.user.service.projection.UserIdNameProjection; -import org.sejongisc.backend.user.util.PasswordPolicyValidator; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.transaction.annotation.Transactional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.sejongisc.backend.common.exception.CustomException; -import org.sejongisc.backend.common.exception.ErrorCode; -import org.sejongisc.backend.auth.dao.UserOauthAccountRepository; -import org.sejongisc.backend.user.dao.UserRepository; -import org.sejongisc.backend.auth.dto.SignupRequest; -import org.sejongisc.backend.auth.dto.SignupResponse; -import org.sejongisc.backend.user.dto.UserUpdateRequest; -import org.sejongisc.backend.user.entity.Role; -import org.sejongisc.backend.user.entity.User; -import org.sejongisc.backend.auth.entity.UserOauthAccount; -import org.sejongisc.backend.auth.oauth.OauthUserInfo; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; - -import java.time.Duration; -import java.util.List; -import java.util.UUID; - - -@Slf4j -@Service -@RequiredArgsConstructor -@Transactional -public class UserServiceImpl implements UserService { - - private final UserRepository userRepository; - private final UserOauthAccountRepository oauthAccountRepository; - private final OauthUnlinkService oauthUnlinkService; - private final PasswordEncoder passwordEncoder; - private final TokenEncryptor tokenEncryptor; - private final EmailService emailService; - private final RedisTemplate redisTemplate; - private final RefreshTokenService refreshTokenService; - private final AccountService accountService; - private final PointLedgerService pointLedgerService; - - - @Override - @Transactional - @OptimisticRetry - public SignupResponse signUp(SignupRequest dto) { - log.debug("[SIGNUP] request: {}", dto.getEmail()); - if (userRepository.existsByEmail(dto.getEmail())) { - throw new CustomException(ErrorCode.DUPLICATE_EMAIL); - } - - if (userRepository.existsByPhoneNumber(dto.getPhoneNumber())) { - throw new CustomException(ErrorCode.DUPLICATE_PHONE); - } - - // trim 적용 후 검증 및 저장 - String rawPassword = dto.getPassword(); - String trimmedPassword = rawPassword == null ? null : rawPassword.trim(); - - // null / 공백 검사 - if (trimmedPassword == null || trimmedPassword.isEmpty()) { - throw new CustomException(ErrorCode.INVALID_INPUT); - } - - // 비밀번호 정책 검증 (trim된 값으로) - PasswordPolicyValidator.validate(trimmedPassword); - - // 패스워드 인코딩 (trim된 값 사용) - String encodedPw = passwordEncoder.encode(trimmedPassword); - - Role role = dto.getRole(); - if (role == null) { - role = Role.TEAM_MEMBER; - } - - User user = User.builder() - .name(dto.getName()) - .email(dto.getEmail()) - .passwordHash(encodedPw) - .role(role) - .point(0) - .phoneNumber(dto.getPhoneNumber()) - .build(); - - try { - User saved = userRepository.save(user); - // 포인트 계정 생성 및 기본 포인트 제공 - completeSignup(saved); - return SignupResponse.from(saved); - } catch (DataIntegrityViolationException e) { - throw new CustomException(ErrorCode.DUPLICATE_USER); - } - - } - - @Override - @Transactional - @OptimisticRetry - public User findOrCreateUser(OauthUserInfo oauthInfo) { - String providerUid = oauthInfo.getProviderUid(); - - // 기존 OAuth 계정 찾기 - return oauthAccountRepository - .findByProviderAndProviderUid(oauthInfo.getProvider(), providerUid) - .map(UserOauthAccount::getUser) - .orElseGet(() -> { - // 새로운 User 생성 - User newUser = User.builder() - .name(oauthInfo.getName()) - .role(Role.TEAM_MEMBER) - .build(); - - User savedUser = userRepository.save(newUser); - - completeSignup(savedUser); - - String encryptedToken = tokenEncryptor.encrypt(oauthInfo.getAccessToken()); - - UserOauthAccount newOauth = UserOauthAccount.builder() - .user(savedUser) - .provider(oauthInfo.getProvider()) - .providerUid(providerUid) - .accessToken(encryptedToken) - .build(); - - oauthAccountRepository.save(newOauth); - - return savedUser; - }); - } - - @Override - @Transactional - public void updateUser(UUID userId, UserUpdateRequest request) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - - // 이름 업데이트 - if (request.getName() != null && !request.getName().trim().isEmpty()) { - user.setName(request.getName().trim()); - } - - // 전화번호 업데이트 - if (request.getPhoneNumber() != null && !request.getPhoneNumber().trim().isEmpty()) { - user.setPhoneNumber(request.getPhoneNumber().trim()); - } - - if (request.getPassword() != null) { - String trimmedPassword = request.getPassword().trim(); - if (trimmedPassword.isEmpty()) { - throw new CustomException(ErrorCode.INVALID_INPUT); - } - user.setPasswordHash(passwordEncoder.encode(trimmedPassword)); - } - - log.info("회원 정보가 수정되었습니다. userId={}", userId); - userRepository.save(user); - } - - @Override - @Transactional - public User getUserById(UUID userId) { - return userRepository.findById(userId) - .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - } - - - @Override - @Transactional - public void deleteUserWithOauth(UUID userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - - // Lazy 로딩 강제 초기화 (안정성 보강) - user.getOauthAccounts().size(); - - // 연동된 OAuth 계정이 있을 경우 모두 해제 - if (!user.getOauthAccounts().isEmpty()) { - for (UserOauthAccount account : user.getOauthAccounts()) { - String provider = account.getProvider().name(); - String providerUid = account.getProviderUid(); - String accessToken = tokenEncryptor.decrypt(account.getAccessToken()); - - log.info("연결된 OAuth 계정 해제 중: provider={}, userId={}", provider, userId); - - // Kakao / Google / GitHub 연동 해제 서비스 연결 - switch (provider.toLowerCase()) { - case "kakao" -> oauthUnlinkService.unlinkKakao(accessToken); - case "google" -> oauthUnlinkService.unlinkGoogle(accessToken); - case "github" -> oauthUnlinkService.unlinkGithub(accessToken); - default -> log.warn("지원하지 않는 provider: {}", provider); - } - } - } - - // Refresh Token (추후 구현 시 삭제) - //refreshTokenRepository.deleteByUserId(userId); - - // User 삭제 (연관된 OAuthAccount는 Cascade로 자동 삭제) - userRepository.delete(user); - log.info("회원 탈퇴 완료: userId={}", userId); - } - - @Override - public String findEmailByNameAndPhone(String name, String phone){ - String normalizedName = name == null ? null : name.trim(); - String normalizedPhone = phone == null ? null : phone.trim(); - - if (normalizedName == null || normalizedName.isEmpty() || - normalizedPhone == null || normalizedPhone.isEmpty()) { - throw new CustomException(ErrorCode.INVALID_INPUT); - } - - return userRepository.findByNameAndPhoneNumber(normalizedName, normalizedPhone) - .map(User::getEmail) - .orElse(null); - } - - @Override - public void passwordReset(String email) { - if (email == null) { - throw new CustomException(ErrorCode.INVALID_INPUT); - } - - String normalizedEmail = email.trim(); - if (normalizedEmail.isEmpty()) { - throw new CustomException(ErrorCode.INVALID_INPUT); - } - - if (!userRepository.existsByEmail(normalizedEmail)) { - log.debug("Password reset requested for non-existent email: {}", normalizedEmail); - return; - } - - // 정상적인 이메일일 경우만 발송 - emailService.sendResetEmail(normalizedEmail); - } - - @Override - public String verifyResetCodeAndIssueToken(String email, String code) { - if (email == null || code == null) { - throw new CustomException(ErrorCode.INVALID_INPUT); - } - - String normalizedEmail = email.trim(); - String normalizedCode = code.trim(); - - if (normalizedEmail.isEmpty() || normalizedCode.isEmpty()) { - throw new CustomException(ErrorCode.INVALID_INPUT); - } - - // 정규화된 값으로 검증 - emailService.verifyResetEmail(normalizedEmail, normalizedCode); - - // 토큰 발급 - String token = UUID.randomUUID().toString(); - - try { - redisTemplate.opsForValue().set( - "PASSWORD_RESET:" + token, - normalizedEmail, - Duration.ofMinutes(10) - ); - } catch (Exception e) { - log.error("Redis 연결 실패: 비밀번호 재설정 토큰 저장 불가", e); - throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); - } - - return token; - } - - @Override - @Transactional - public void resetPasswordByToken(String resetToken, String newPassword) { -// String email = (String) redisTemplate.opsForValue().get("PASSWORD_RESET:" + resetToken); - String email = null; - - try { - email = (String) redisTemplate.opsForValue().get("PASSWORD_RESET:" + resetToken); - } catch (Exception e) { - log.error("Redis 연결 실패 - 비밀번호 재설정 토큰 조회 불가", e); - throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); - } - - if(email == null) { - throw new CustomException(ErrorCode.EMAIL_CODE_NOT_FOUND); - } - - User user = userRepository.findUserByEmail(email) - .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - - if (newPassword == null) { - throw new CustomException(ErrorCode.INVALID_INPUT); - } - - String trimmedPassword = newPassword.trim(); - if (trimmedPassword.isEmpty()) { - throw new CustomException(ErrorCode.INVALID_INPUT); - } - - // 반드시 trim된 값으로 정책 검증 - PasswordPolicyValidator.validate(trimmedPassword); - - // trim된 값을 인코딩하여 저장 - user.setPasswordHash(passwordEncoder.encode(trimmedPassword)); - userRepository.save(user); - - try { - redisTemplate.delete("PASSWORD_RESET:" + resetToken); - } catch (Exception e) { - log.error("Redis 연결 실패 - 비밀번호 재설정 토큰 삭제 불가", e); - throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); - } - - refreshTokenService.deleteByUserId(user.getUserId()); - } - - - - @Override - @Transactional - public User upsertOAuthUser(String provider, String providerUid, String email, String name) { - - AuthProvider authProvider = AuthProvider.valueOf(provider.toUpperCase()); - - return oauthAccountRepository - .findByProviderAndProviderUid(authProvider, providerUid) - .map(UserOauthAccount::getUser) - .orElseGet(() -> { - User newUser = User.builder() - .email(email) - .name(name) - .role(Role.TEAM_MEMBER) - .build(); - - User savedUser = userRepository.save(newUser); - - UserOauthAccount oauthAccount = UserOauthAccount.builder() - .user(savedUser) - .provider(authProvider) - .providerUid(providerUid) - .build(); - - oauthAccountRepository.save(oauthAccount); - - return savedUser; - }); - } - - @Override - public List getUserProjectionList() { - return userRepository.findAllUserIdAndName(); - } - - /** - * 포인트 계정이 존재하지 않는 사용자 리스트 조회 - */ - @Override - public List findAllUsersMissingAccount() { - return userRepository.findAllUsersMissingAccount(); - } - - /** - * 사용자의 포인트 계정 생성 및 기본 포인트 지급 - */ - private void completeSignup(User user) { - // 사용자의 포인트 계정 생성 - Account userAccount = accountService.createUserAccount(user.getUserId()); - - // 회원가입 포인트 지급 - pointLedgerService.processTransaction( - TransactionReason.SIGNUP_REWARD, - user.getUserId(), - AccountEntry.credit(accountService.getAccountByName(AccountName.SYSTEM_ISSUANCE), 100L), - AccountEntry.debit(userAccount, 100L) - ); - - log.info("회원가입 완료: 회원가입 및 초기 포인트 지급이 완료되었습니다. User: {}", user.getEmail()); - } -} diff --git a/backend/src/main/java/org/sejongisc/backend/user/util/PasswordPolicyValidator.java b/backend/src/main/java/org/sejongisc/backend/user/util/PasswordPolicyValidator.java index 4809eb2c..2cbd3901 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/util/PasswordPolicyValidator.java +++ b/backend/src/main/java/org/sejongisc/backend/user/util/PasswordPolicyValidator.java @@ -11,12 +11,20 @@ public class PasswordPolicyValidator { private static final Pattern PASSWORD_PATTERN = Pattern.compile("^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d)(?=.*[!@#$%^&*()_+=\\-{};:'\",.<>/?]).{8,20}$"); - public static void validate(String password) { - if (password == null || password.trim().isEmpty()) { + public static String getValidatedPassword(String password) { + String trimmed = sanitize(password); + + if (!PASSWORD_PATTERN.matcher(trimmed).matches()) { throw new CustomException(ErrorCode.INVALID_INPUT); } - if (!PASSWORD_PATTERN.matcher(password).matches()) { + + return trimmed; + } + + private static String sanitize(String password) { + if (password == null || password.isBlank()) { throw new CustomException(ErrorCode.INVALID_INPUT); } + return password.trim(); } } diff --git a/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceControllerTest.java b/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceControllerTest.java index 61e36c7e..635df7a5 100644 --- a/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceControllerTest.java +++ b/backend/src/test/java/org/sejongisc/backend/attendance/controller/AttendanceControllerTest.java @@ -2,34 +2,17 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.sejongisc.backend.attendance.service.AttendanceService; -import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; -import org.sejongisc.backend.user.entity.Role; -import org.sejongisc.backend.user.entity.User; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.context.annotation.Import; import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; -import org.springframework.http.MediaType; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import java.time.LocalDateTime; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.UUID; - -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(AttendanceController.class) @Import(TestSecurityConfig.class) diff --git a/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceRoundCheckInTest.java b/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceRoundCheckInTest.java index 763c22d4..a716dd62 100644 --- a/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceRoundCheckInTest.java +++ b/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceRoundCheckInTest.java @@ -1,7 +1,5 @@ package org.sejongisc.backend.attendance.service; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -9,20 +7,11 @@ import org.sejongisc.backend.attendance.repository.AttendanceRepository; import org.sejongisc.backend.attendance.repository.AttendanceRoundRepository; import org.sejongisc.backend.attendance.repository.AttendanceSessionRepository; -import org.sejongisc.backend.user.dao.UserRepository; -import org.sejongisc.backend.user.entity.User; +import org.sejongisc.backend.user.repository.UserRepository; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.util.Optional; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.*; import static org.junit.jupiter.api.Assertions.assertAll; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) public class AttendanceRoundCheckInTest { diff --git a/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceServiceTest.java b/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceServiceTest.java index e5f48360..d9719910 100644 --- a/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceServiceTest.java +++ b/backend/src/test/java/org/sejongisc/backend/attendance/service/AttendanceServiceTest.java @@ -1,24 +1,14 @@ package org.sejongisc.backend.attendance.service; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.sejongisc.backend.attendance.entity.*; import org.sejongisc.backend.attendance.repository.AttendanceRepository; import org.sejongisc.backend.attendance.repository.AttendanceSessionRepository; -import org.sejongisc.backend.user.dao.UserRepository; -import org.sejongisc.backend.user.entity.Role; -import org.sejongisc.backend.user.entity.User; +import org.sejongisc.backend.user.repository.UserRepository; -import java.time.LocalDateTime; -import java.util.*; - -import static org.assertj.core.api.Assertions.*; import static org.junit.jupiter.api.Assertions.assertAll; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) public class AttendanceServiceTest { diff --git a/backend/src/test/java/org/sejongisc/backend/auth/controller/AuthControllerTest.java b/backend/src/test/java/org/sejongisc/backend/auth/controller/AuthControllerTest.java index d75008f9..c3b8de4a 100644 --- a/backend/src/test/java/org/sejongisc/backend/auth/controller/AuthControllerTest.java +++ b/backend/src/test/java/org/sejongisc/backend/auth/controller/AuthControllerTest.java @@ -11,13 +11,15 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.sejongisc.backend.auth.dto.*; -import org.sejongisc.backend.auth.service.LoginService; -import org.sejongisc.backend.auth.service.Oauth2Service; -import org.sejongisc.backend.auth.service.OauthStateService; -import org.sejongisc.backend.auth.service.RefreshTokenService; +import org.sejongisc.backend.common.auth.controller.AuthController; +import org.sejongisc.backend.common.auth.controller.AuthCookieHelper; +import org.sejongisc.backend.common.auth.dto.*; +import org.sejongisc.backend.common.auth.dto.oauth.*; +import org.sejongisc.backend.common.auth.service.AuthService; +import org.sejongisc.backend.common.auth.service.oauth2.Oauth2Service; +import org.sejongisc.backend.common.auth.service.oauth2.OauthStateService; +import org.sejongisc.backend.common.auth.service.RefreshTokenService; import org.sejongisc.backend.common.auth.jwt.JwtProvider; -import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; import org.sejongisc.backend.common.exception.controller.GlobalExceptionHandler; @@ -58,11 +60,14 @@ class AuthControllerTest { @Mock Oauth2Service kakaoService; @Mock Oauth2Service githubService; - @Mock LoginService loginService; + @Mock + AuthService authService; @Mock UserService userService; @Mock JwtProvider jwtProvider; @Mock OauthStateService oauthStateService; @Mock RefreshTokenService refreshTokenService; + @Mock + AuthCookieHelper authCookieHelper; @InjectMocks AuthController authController; @@ -78,14 +83,7 @@ void setUp() { "GITHUB", githubService ); - authController = new AuthController( - oauth2Services, - loginService, - userService, - jwtProvider, - oauthStateService, - refreshTokenService - ); + authController = new AuthController(authService, refreshTokenService, authCookieHelper); objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); @@ -115,8 +113,8 @@ public ResponseEntity> handle(MethodArgumentNotValidExceptio @Test @DisplayName("POST /api/auth/login - 로그인 성공 시 200 OK") void login_success() throws Exception { - LoginRequest req = new LoginRequest("hong@example.com", "Password123!"); - LoginResponse resp = LoginResponse.builder() + AuthRequest req = new AuthRequest("hong@example.com", "Password123!"); + AuthResponse resp = AuthResponse.builder() .accessToken("mockAccessToken") .refreshToken("mockRefreshToken") .userId(UUID.randomUUID()) @@ -126,7 +124,7 @@ void login_success() throws Exception { .point(100) .build(); - when(loginService.login(any(LoginRequest.class))).thenReturn(resp); + when(authService.login(any(AuthRequest.class))).thenReturn(resp); mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) @@ -143,10 +141,10 @@ void login_success() throws Exception { @Test @DisplayName("POST /api/auth/login - 존재하지 않는 사용자면 404 반환") void login_userNotFound() throws Exception { - when(loginService.login(any(LoginRequest.class))) + when(authService.login(any(AuthRequest.class))) .thenThrow(new CustomException(ErrorCode.USER_NOT_FOUND)); - LoginRequest req = new LoginRequest("notfound@example.com", "Password123!"); + AuthRequest req = new AuthRequest("notfound@example.com", "Password123!"); mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) @@ -158,10 +156,10 @@ void login_userNotFound() throws Exception { @Test @DisplayName("POST /api/auth/login - 비밀번호 틀리면 401 반환") void login_wrongPassword() throws Exception { - when(loginService.login(any(LoginRequest.class))) + when(authService.login(any(AuthRequest.class))) .thenThrow(new CustomException(ErrorCode.UNAUTHORIZED)); - LoginRequest req = new LoginRequest("hong@example.com", "WrongPassword!"); + AuthRequest req = new AuthRequest("hong@example.com", "WrongPassword!"); mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) @@ -261,7 +259,7 @@ void logout_success() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("로그아웃 성공")); - verify(loginService, times(1)).logout(token); + verify(authService, times(1)).logout(token); } // Authorization 헤더 누락 @@ -271,14 +269,14 @@ void logout_missingHeader() throws Exception { mockMvc.perform(post("/api/auth/logout")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.message").value("잘못된 Authorization 헤더 형식입니다.")); - verify(loginService, never()).logout(anyString()); + verify(authService, never()).logout(anyString()); } // 잘못된 토큰 (JwtException) @Test @DisplayName("POST /api/auth/logout - 잘못된 토큰이어도 200 OK 응답 (멱등성 보장)") void logout_invalidToken() throws Exception { - doThrow(new JwtException("Invalid Token")).when(loginService).logout(anyString()); + doThrow(new JwtException("Invalid Token")).when(authService).logout(anyString()); mockMvc.perform(post("/api/auth/logout") .header(HttpHeaders.AUTHORIZATION, "Bearer invalid.token")) @@ -294,7 +292,7 @@ void signup_success() throws Exception { .name("홍길동") .email("hong@example.com") .password("Password123!") - .role(Role.TEAM_MEMBER) + //.role(Role.TEAM_MEMBER) .phoneNumber("01012345678") .build(); @@ -309,7 +307,7 @@ void signup_success() throws Exception { .build(); SignupResponse resp = SignupResponse.from(entity); - when(userService.signUp(any(SignupRequest.class))).thenReturn(resp); + when(userService.signup(any(SignupRequest.class))).thenReturn(resp); mockMvc.perform(post("/api/auth/signup") .contentType(MediaType.APPLICATION_JSON) diff --git a/backend/src/test/java/org/sejongisc/backend/auth/service/LoginServiceImplTest.java b/backend/src/test/java/org/sejongisc/backend/auth/service/AuthServiceTest.java similarity index 82% rename from backend/src/test/java/org/sejongisc/backend/auth/service/LoginServiceImplTest.java rename to backend/src/test/java/org/sejongisc/backend/auth/service/AuthServiceTest.java index 318de394..ddaecbf5 100644 --- a/backend/src/test/java/org/sejongisc/backend/auth/service/LoginServiceImplTest.java +++ b/backend/src/test/java/org/sejongisc/backend/auth/service/AuthServiceTest.java @@ -6,18 +6,17 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.sejongisc.backend.auth.repository.RefreshTokenRepository; +import org.sejongisc.backend.common.auth.repository.RefreshTokenRepository; import org.sejongisc.backend.common.auth.jwt.JwtParser; import org.sejongisc.backend.common.auth.jwt.JwtProvider; +import org.sejongisc.backend.common.auth.service.AuthService; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; -import org.sejongisc.backend.user.dao.UserRepository; -import org.sejongisc.backend.auth.dto.LoginRequest; -import org.sejongisc.backend.auth.dto.LoginResponse; +import org.sejongisc.backend.user.repository.UserRepository; +import org.sejongisc.backend.common.auth.dto.AuthRequest; +import org.sejongisc.backend.common.auth.dto.AuthResponse; import org.sejongisc.backend.user.entity.Role; import org.sejongisc.backend.user.entity.User; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.crypto.password.PasswordEncoder; import java.util.Optional; @@ -26,12 +25,11 @@ import static org.mockito.Mockito.*; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.given; @ExtendWith(MockitoExtension.class) -class LoginServiceImplTest { +class AuthServiceTest { @Mock private UserRepository userRepository; @@ -50,7 +48,7 @@ class LoginServiceImplTest { @InjectMocks - private LoginServiceImpl loginService; + private AuthService authService; @Test @DisplayName("정상 로그인 시 LoginResponse 반환") @@ -69,8 +67,8 @@ void login_success() { .point(100) .build(); - LoginRequest request = new LoginRequest(); - request.setEmail("test@example.com"); + AuthRequest request = new AuthRequest(); + request.setStudentId("test@example.com"); request.setPassword(rawPassword); given(userRepository.findUserByEmail("test@example.com")) @@ -80,7 +78,7 @@ void login_success() { .willReturn("mocked-jwt-token"); // when - LoginResponse response = loginService.login(request); + AuthResponse response = authService.login(request); // then assertThat(response).isNotNull(); @@ -95,8 +93,8 @@ void login_success() { @DisplayName("이메일이 존재하지 않으면 USER_NOT_FOUND 예외 발생") void login_userNotFound() { // given - LoginRequest request = new LoginRequest(); - request.setEmail("notfound@example.com"); + AuthRequest request = new AuthRequest(); + request.setStudentId("notfound@example.com"); request.setPassword("password"); given(userRepository.findUserByEmail("notfound@example.com")) @@ -104,7 +102,7 @@ void login_userNotFound() { // when & then CustomException exception = assertThrows(CustomException.class, - () -> loginService.login(request)); + () -> authService.login(request)); assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.USER_NOT_FOUND); } @@ -124,8 +122,8 @@ void login_wrongPassword() { .point(50) .build(); - LoginRequest request = new LoginRequest(); - request.setEmail("test@example.com"); + AuthRequest request = new AuthRequest(); + request.setStudentId("test@example.com"); request.setPassword("wrongPassword"); given(userRepository.findUserByEmail("test@example.com")) @@ -135,7 +133,7 @@ void login_wrongPassword() { // when & then CustomException exception = assertThrows(CustomException.class, - () -> loginService.login(request)); + () -> authService.login(request)); assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED); } @@ -154,8 +152,8 @@ void login_nullPassword() { .point(0) .build(); - LoginRequest request = new LoginRequest(); - request.setEmail("test@example.com"); + AuthRequest request = new AuthRequest(); + request.setStudentId("test@example.com"); request.setPassword("somePassword"); given(userRepository.findUserByEmail("test@example.com")) @@ -163,7 +161,7 @@ void login_nullPassword() { // when & then CustomException exception = assertThrows(CustomException.class, - () -> loginService.login(request)); + () -> authService.login(request)); assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED); } @@ -179,7 +177,7 @@ void logout_success() { when(jwtParser.getUserIdFromToken(fakeToken)).thenReturn(userId); - loginService.logout(fakeToken); + authService.logout(fakeToken); verify(jwtParser, times(1)).getUserIdFromToken(fakeToken); diff --git a/backend/src/test/java/org/sejongisc/backend/auth/service/EmailServiceTest.java b/backend/src/test/java/org/sejongisc/backend/auth/service/EmailServiceTest.java index 9c5484bb..20e37191 100644 --- a/backend/src/test/java/org/sejongisc/backend/auth/service/EmailServiceTest.java +++ b/backend/src/test/java/org/sejongisc/backend/auth/service/EmailServiceTest.java @@ -17,8 +17,9 @@ import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; -import org.sejongisc.backend.auth.config.EmailProperties; -import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.common.config.EmailProperties; +import org.sejongisc.backend.common.auth.service.EmailService; +import org.sejongisc.backend.user.repository.UserRepository; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.mail.javamail.JavaMailSender; diff --git a/backend/src/test/java/org/sejongisc/backend/auth/service/GithubServiceImplTest.java b/backend/src/test/java/org/sejongisc/backend/auth/service/GithubServiceImplTest.java index 610c73cf..7bc7676d 100644 --- a/backend/src/test/java/org/sejongisc/backend/auth/service/GithubServiceImplTest.java +++ b/backend/src/test/java/org/sejongisc/backend/auth/service/GithubServiceImplTest.java @@ -6,8 +6,9 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.sejongisc.backend.auth.dto.GithubTokenResponse; -import org.sejongisc.backend.auth.dto.GithubUserInfoResponse; +import org.sejongisc.backend.common.auth.dto.oauth.GithubTokenResponse; +import org.sejongisc.backend.common.auth.dto.oauth.GithubUserInfoResponse; +import org.sejongisc.backend.common.auth.service.oauth2.GithubServiceImpl; import java.io.IOException; diff --git a/backend/src/test/java/org/sejongisc/backend/auth/service/GoogleServiceImplTest.java b/backend/src/test/java/org/sejongisc/backend/auth/service/GoogleServiceImplTest.java index 6fc2ef9b..a995b160 100644 --- a/backend/src/test/java/org/sejongisc/backend/auth/service/GoogleServiceImplTest.java +++ b/backend/src/test/java/org/sejongisc/backend/auth/service/GoogleServiceImplTest.java @@ -6,8 +6,9 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.sejongisc.backend.auth.dto.GoogleTokenResponse; -import org.sejongisc.backend.auth.dto.GoogleUserInfoResponse; +import org.sejongisc.backend.common.auth.dto.oauth.GoogleTokenResponse; +import org.sejongisc.backend.common.auth.dto.oauth.GoogleUserInfoResponse; +import org.sejongisc.backend.common.auth.service.oauth2.GoogleServiceImpl; import java.io.IOException; diff --git a/backend/src/test/java/org/sejongisc/backend/auth/service/KakaoServiceImplTest.java b/backend/src/test/java/org/sejongisc/backend/auth/service/KakaoServiceImplTest.java index 24444f4f..31bab628 100644 --- a/backend/src/test/java/org/sejongisc/backend/auth/service/KakaoServiceImplTest.java +++ b/backend/src/test/java/org/sejongisc/backend/auth/service/KakaoServiceImplTest.java @@ -6,8 +6,8 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.sejongisc.backend.auth.dto.KakaoTokenResponse; -import org.sejongisc.backend.auth.dto.KakaoUserInfoResponse; +import org.sejongisc.backend.common.auth.dto.oauth.KakaoUserInfoResponse; +import org.sejongisc.backend.common.auth.service.oauth2.KakaoServiceImpl; import java.io.IOException; diff --git a/backend/src/test/java/org/sejongisc/backend/auth/service/OauthUnlinkServiceImplTest.java b/backend/src/test/java/org/sejongisc/backend/auth/service/OauthUnlinkServiceImplTest.java index 7129473e..dd1cab8a 100644 --- a/backend/src/test/java/org/sejongisc/backend/auth/service/OauthUnlinkServiceImplTest.java +++ b/backend/src/test/java/org/sejongisc/backend/auth/service/OauthUnlinkServiceImplTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.*; +import org.sejongisc.backend.common.auth.service.oauth2.OauthUnlinkServiceImpl; import org.springframework.http.*; import org.springframework.web.client.RestTemplate; diff --git a/backend/src/test/java/org/sejongisc/backend/auth/service/RefreshTokenServiceImplTest.java b/backend/src/test/java/org/sejongisc/backend/auth/service/RefreshTokenServiceTest.java similarity index 94% rename from backend/src/test/java/org/sejongisc/backend/auth/service/RefreshTokenServiceImplTest.java rename to backend/src/test/java/org/sejongisc/backend/auth/service/RefreshTokenServiceTest.java index 2454352f..d869aacd 100644 --- a/backend/src/test/java/org/sejongisc/backend/auth/service/RefreshTokenServiceImplTest.java +++ b/backend/src/test/java/org/sejongisc/backend/auth/service/RefreshTokenServiceTest.java @@ -6,12 +6,13 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.mockito.junit.jupiter.MockitoExtension; -import org.sejongisc.backend.auth.entity.RefreshToken; -import org.sejongisc.backend.auth.repository.RefreshTokenRepository; +import org.sejongisc.backend.common.auth.entity.RefreshToken; +import org.sejongisc.backend.common.auth.repository.RefreshTokenRepository; import org.sejongisc.backend.common.auth.jwt.JwtProvider; +import org.sejongisc.backend.common.auth.service.RefreshTokenService; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; -import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.user.repository.UserRepository; import org.sejongisc.backend.user.entity.Role; import org.sejongisc.backend.user.entity.User; @@ -22,7 +23,7 @@ @ExtendWith(MockitoExtension.class) -class RefreshTokenServiceImplTest { +class RefreshTokenServiceTest { @Mock private RefreshTokenRepository refreshTokenRepository; @@ -34,7 +35,7 @@ class RefreshTokenServiceImplTest { private JwtProvider jwtProvider; @InjectMocks - private RefreshTokenServiceImpl refreshTokenService; + private RefreshTokenService refreshTokenService; private UUID userId; private String refreshToken; diff --git a/backend/src/test/java/org/sejongisc/backend/backtest/controller/BacktestControllerTest.java b/backend/src/test/java/org/sejongisc/backend/backtest/controller/BacktestControllerTest.java index a8d92e56..19fd095d 100644 --- a/backend/src/test/java/org/sejongisc/backend/backtest/controller/BacktestControllerTest.java +++ b/backend/src/test/java/org/sejongisc/backend/backtest/controller/BacktestControllerTest.java @@ -10,7 +10,7 @@ //import org.sejongisc.backend.backtest.service.BacktestService; //import org.sejongisc.backend.common.auth.config.SecurityConfig; //import org.sejongisc.backend.common.auth.jwt.JwtParser; -//import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; +//import org.sejongisc.backend.common.auth.dto.LoginResponse.CustomUserDetails; //import org.sejongisc.backend.user.entity.Role; //import org.sejongisc.backend.user.entity.User; //import org.springframework.beans.factory.annotation.Autowired; diff --git a/backend/src/test/java/org/sejongisc/backend/betting/service/BettingServiceTransactionalTest.java b/backend/src/test/java/org/sejongisc/backend/betting/service/BettingServiceTransactionalTest.java index 2bb139ee..b641e01b 100644 --- a/backend/src/test/java/org/sejongisc/backend/betting/service/BettingServiceTransactionalTest.java +++ b/backend/src/test/java/org/sejongisc/backend/betting/service/BettingServiceTransactionalTest.java @@ -19,7 +19,7 @@ import org.sejongisc.backend.point.entity.PointHistory; import org.sejongisc.backend.point.repository.PointHistoryRepository; import org.sejongisc.backend.point.service.PointHistoryService; -import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.user.repository.UserRepository; import org.sejongisc.backend.user.entity.User; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; diff --git a/backend/src/test/java/org/sejongisc/backend/board/service/PostInteractionServiceTest.java b/backend/src/test/java/org/sejongisc/backend/board/service/PostInteractionServiceTest.java index b9ae930e..05049a32 100644 --- a/backend/src/test/java/org/sejongisc/backend/board/service/PostInteractionServiceTest.java +++ b/backend/src/test/java/org/sejongisc/backend/board/service/PostInteractionServiceTest.java @@ -31,7 +31,7 @@ import org.sejongisc.backend.board.repository.PostRepository; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; -import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.user.repository.UserRepository; import org.sejongisc.backend.user.entity.Role; import org.sejongisc.backend.user.entity.User; diff --git a/backend/src/test/java/org/sejongisc/backend/board/service/PostServiceImplTest.java b/backend/src/test/java/org/sejongisc/backend/board/service/PostServiceImplTest.java index 8dd734c9..efd1f4fb 100644 --- a/backend/src/test/java/org/sejongisc/backend/board/service/PostServiceImplTest.java +++ b/backend/src/test/java/org/sejongisc/backend/board/service/PostServiceImplTest.java @@ -37,7 +37,7 @@ import org.sejongisc.backend.board.repository.PostRepository; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; -import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.user.repository.UserRepository; import org.sejongisc.backend.user.entity.Role; import org.sejongisc.backend.user.entity.User; import org.springframework.data.domain.Page; diff --git a/backend/src/test/java/org/sejongisc/backend/template/controller/TemplateControllerTest.java b/backend/src/test/java/org/sejongisc/backend/template/controller/TemplateControllerTest.java index 1a1c0080..fc69ba0a 100644 --- a/backend/src/test/java/org/sejongisc/backend/template/controller/TemplateControllerTest.java +++ b/backend/src/test/java/org/sejongisc/backend/template/controller/TemplateControllerTest.java @@ -3,10 +3,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.sejongisc.backend.common.auth.config.SecurityConfig; +import org.sejongisc.backend.common.auth.dto.CustomUserDetails; +import org.sejongisc.backend.common.config.security.SecurityConfig; import org.sejongisc.backend.common.auth.jwt.JwtParser; -import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; -import org.sejongisc.backend.common.auth.springsecurity.JwtAuthenticationFilter; import org.sejongisc.backend.template.dto.TemplateRequest; import org.sejongisc.backend.template.dto.TemplateResponse; import org.sejongisc.backend.template.entity.Template; @@ -14,7 +13,6 @@ import org.sejongisc.backend.user.entity.Role; import org.sejongisc.backend.user.entity.User; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.context.TestConfiguration; @@ -25,21 +23,15 @@ import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; -import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.UUID; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; diff --git a/backend/src/test/java/org/sejongisc/backend/template/service/TemplateServiceTest.java b/backend/src/test/java/org/sejongisc/backend/template/service/TemplateServiceTest.java index 44e6ef15..ded275e1 100644 --- a/backend/src/test/java/org/sejongisc/backend/template/service/TemplateServiceTest.java +++ b/backend/src/test/java/org/sejongisc/backend/template/service/TemplateServiceTest.java @@ -14,7 +14,7 @@ import org.sejongisc.backend.template.entity.Template; import org.sejongisc.backend.template.repository.TemplateRepository; import org.sejongisc.backend.user.entity.User; -import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.user.repository.UserRepository; import java.util.Optional; import java.util.UUID; diff --git a/backend/src/test/java/org/sejongisc/backend/user/controller/UserControllerTest.java b/backend/src/test/java/org/sejongisc/backend/user/controller/UserControllerTest.java index 5c304214..2b8f3cd8 100644 --- a/backend/src/test/java/org/sejongisc/backend/user/controller/UserControllerTest.java +++ b/backend/src/test/java/org/sejongisc/backend/user/controller/UserControllerTest.java @@ -8,13 +8,10 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; -import org.sejongisc.backend.auth.dto.SignupRequest; -import org.sejongisc.backend.auth.dto.SignupResponse; +import org.sejongisc.backend.common.auth.dto.CustomUserDetails; import org.sejongisc.backend.user.entity.Role; import org.sejongisc.backend.user.entity.User; import org.sejongisc.backend.user.service.UserService; -import org.springframework.http.MediaType; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; @@ -23,12 +20,10 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; -import java.time.LocalDateTime; import java.util.UUID; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; diff --git a/backend/src/test/java/org/sejongisc/backend/user/service/UserServiceImplTest.java b/backend/src/test/java/org/sejongisc/backend/user/service/UserServiceTest.java similarity index 86% rename from backend/src/test/java/org/sejongisc/backend/user/service/UserServiceImplTest.java rename to backend/src/test/java/org/sejongisc/backend/user/service/UserServiceTest.java index a18224ba..fa8792aa 100644 --- a/backend/src/test/java/org/sejongisc/backend/user/service/UserServiceImplTest.java +++ b/backend/src/test/java/org/sejongisc/backend/user/service/UserServiceTest.java @@ -16,25 +16,25 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.sejongisc.backend.auth.service.EmailService; -import org.sejongisc.backend.auth.service.RefreshTokenService; +import org.sejongisc.backend.common.auth.service.EmailService; +import org.sejongisc.backend.common.auth.service.RefreshTokenService; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; -import org.sejongisc.backend.auth.dao.UserOauthAccountRepository; -import org.sejongisc.backend.user.dao.UserRepository; -import org.sejongisc.backend.auth.dto.SignupRequest; -import org.sejongisc.backend.auth.dto.SignupResponse; -import org.sejongisc.backend.auth.entity.AuthProvider; +import org.sejongisc.backend.common.auth.repository.UserOauthAccountRepository; +import org.sejongisc.backend.user.repository.UserRepository; +import org.sejongisc.backend.common.auth.dto.SignupRequest; +import org.sejongisc.backend.common.auth.dto.SignupResponse; +import org.sejongisc.backend.common.auth.entity.AuthProvider; import org.sejongisc.backend.user.dto.UserUpdateRequest; import org.sejongisc.backend.user.entity.Role; import org.sejongisc.backend.user.entity.User; -import org.sejongisc.backend.auth.entity.UserOauthAccount; -import org.sejongisc.backend.auth.oauth.OauthUserInfo; +import org.sejongisc.backend.common.auth.entity.UserOauthAccount; +import org.sejongisc.backend.common.auth.dto.oauth.OauthUserInfo; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.crypto.password.PasswordEncoder; @ExtendWith(MockitoExtension.class) -class UserServiceImplTest { +class UserServiceTest { @Mock private UserRepository userRepository; @@ -57,17 +57,17 @@ class UserServiceImplTest { @Mock private org.sejongisc.backend.common.auth.jwt.TokenEncryptor tokenEncryptor; - @InjectMocks private UserServiceImpl userService; + @InjectMocks private UserService userService; @Test @DisplayName("회원가입 성공: 비밀번호 인코딩, 저장, DTO 매핑 확인") - void signUp_success() { + void signup_success() { // given SignupRequest req = SignupRequest.builder() .name("홍길동") .email("hong@example.com") .password("Password123!") - .role(Role.TEAM_MEMBER) + //.role(Role.TEAM_MEMBER) .phoneNumber("01012345678") .build(); @@ -89,7 +89,7 @@ void signUp_success() { }); // when - SignupResponse res = userService.signUp(req); + SignupResponse res = userService.signup(req); // then assertAll( @@ -109,20 +109,20 @@ void signUp_success() { @Test @DisplayName("회원가입 실패: 이메일 중복이면 CustomException(DUPLICATE_EMAIL)") - void signUp_duplicateEmail_throws() { + void signup_duplicateEmail_throws() { // given SignupRequest req = SignupRequest.builder() .name("홍길동") .email("dup@example.com") .password("Password123!") - .role(Role.TEAM_MEMBER) + //.role(Role.TEAM_MEMBER) .phoneNumber("01012345678") .build(); when(userRepository.existsByEmail(req.getEmail())).thenReturn(true); // when - CustomException ex = assertThrows(CustomException.class, () -> userService.signUp(req)); + CustomException ex = assertThrows(CustomException.class, () -> userService.signup(req)); // then assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.DUPLICATE_EMAIL); @@ -134,7 +134,7 @@ void signUp_duplicateEmail_throws() { @Test @DisplayName("회원가입: Role이 null이면 기본값 MEMBER로 저장") - void signUp_nullRole_defaultsToMember() { + void signup_nullRole_defaultsToMember() { // given SignupRequest req = SignupRequest.builder() .name("이몽룡") @@ -161,7 +161,7 @@ void signUp_nullRole_defaultsToMember() { }); // when - SignupResponse res = userService.signUp(req); + SignupResponse res = userService.signup(req); // then assertThat(res.getRole()).isEqualTo(Role.TEAM_MEMBER); @@ -169,13 +169,13 @@ void signUp_nullRole_defaultsToMember() { @Test @DisplayName("회원가입 실패: 전화번호 중복이면 CustomException(DUPLICATE_PHONE)") - void signUp_duplicatePhone_throws() { + void signup_duplicatePhone_throws() { // given SignupRequest req = SignupRequest.builder() .name("성춘향") .email("spring@example.com") .password("Password!123") - .role(Role.TEAM_MEMBER) + //.role(Role.TEAM_MEMBER) .phoneNumber("01011112222") .build(); @@ -183,7 +183,7 @@ void signUp_duplicatePhone_throws() { when(userRepository.existsByPhoneNumber(req.getPhoneNumber())).thenReturn(true); // when - CustomException ex = assertThrows(CustomException.class, () -> userService.signUp(req)); + CustomException ex = assertThrows(CustomException.class, () -> userService.signup(req)); // then assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.DUPLICATE_PHONE); @@ -196,13 +196,13 @@ void signUp_duplicatePhone_throws() { @Test @DisplayName("회원가입 실패: DB 무결성 제약 위반 시 CustomException(DUPLICATE_USER)") - void signUp_dataIntegrityViolation_throws() { + void signup_dataIntegrityViolation_throws() { // given SignupRequest req = SignupRequest.builder() .name("임꺽정") .email("im@example.com") .password("Pw123456!") - .role(Role.TEAM_MEMBER) + //.role(Role.TEAM_MEMBER) .phoneNumber("01077778888") .build(); @@ -214,7 +214,7 @@ void signUp_dataIntegrityViolation_throws() { .thenThrow(new org.springframework.dao.DataIntegrityViolationException("constraint")); // when - CustomException ex = assertThrows(CustomException.class, () -> userService.signUp(req)); + CustomException ex = assertThrows(CustomException.class, () -> userService.signup(req)); // then assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.DUPLICATE_USER); @@ -288,42 +288,7 @@ void findOrCreateUser_newUser() { verify(oauthAccountRepository).save(any(UserOauthAccount.class)); } - @Test - @DisplayName("회원정보 수정 성공: 이름, 전화번호, 비밀번호 변경") - void updateUser_success() { - // given - UUID userId = UUID.randomUUID(); - User existingUser = User.builder() - .userId(userId) - .name("기존이름") - .phoneNumber("010-1111-1111") - .passwordHash("OLD_HASH") - .role(Role.TEAM_MEMBER) - .build(); - when(userRepository.findById(userId)).thenReturn(Optional.of(existingUser)); - when(passwordEncoder.encode("newPassword123")).thenReturn("NEW_HASH"); - - // 수정 요청 DTO - var request = new org.sejongisc.backend.user.dto.UserUpdateRequest(); - request.setName("새이름"); - request.setPhoneNumber("010-2222-3333"); - request.setPassword("newPassword123"); - - // when - userService.updateUser(userId, request); - - // then - assertAll( - () -> assertThat(existingUser.getName()).isEqualTo("새이름"), - () -> assertThat(existingUser.getPhoneNumber()).isEqualTo("010-2222-3333"), - () -> assertThat(existingUser.getPasswordHash()).isEqualTo("NEW_HASH") - ); - - verify(userRepository).findById(userId); - verify(passwordEncoder).encode("newPassword123"); - verify(userRepository).save(existingUser); - } @Test @DisplayName("회원정보 수정 실패: 존재하지 않는 사용자일 경우 예외 발생")