kos-mock 상품변경 실제 DB 업데이트 기능 추가

- MockDataService에 updateCustomerProduct 메서드 추가
- KosMockService에 실제 고객 데이터 업데이트 로직 추가
- 상품변경 시 고객의 current_product_code를 실제로 업데이트하도록 수정
- 트랜잭션 처리로 데이터 일관성 보장
- product-service Hibernate dialect 설정 추가

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
hiondal
2025-09-10 02:06:24 +09:00
parent 6ca4daed8d
commit 02bcfa5434
122 changed files with 6116 additions and 3983 deletions
+4 -5
View File
@@ -4,6 +4,7 @@
<option name="env">
<map>
<entry key="BILL_INQUIRY_URL" value="http://localhost:8082" />
<entry key="CORS_ALLOWED_ORIGINS" value="http://localhost:3000" />
<entry key="DB_HOST" value="20.249.70.6" />
<entry key="DB_KIND" value="postgresql" />
<entry key="DB_NAME" value="phonebill_auth" />
@@ -11,11 +12,9 @@
<entry key="DB_PORT" value="5432" />
<entry key="DB_USERNAME" value="auth_user" />
<entry key="DDL_AUTO" value="update" />
<entry key="JWT_SECRET" value="phonebill-jwt-secret-key-2025-dev" />
<entry key="LOG_FILE" value="logs/user-service.log" />
<entry key="LOG_LEVEL_APP" value="DEBUG" />
<entry key="LOG_LEVEL_ROOT" value="INFO" />
<entry key="PRODUCT_CHANGE_URL" value="http://localhost:8083" />
<entry key="JWT_ACCESS_TOKEN_VALIDITY" value="18000000" />
<entry key="JWT_REFRESH_TOKEN_VALIDITY" value="86400000" />
<entry key="JWT_SECRET" value="nwe5Yo9qaJ6FBD/Thl2/j6/SFAfNwUorAY1ZcWO2KI7uA4bmVLOCPxE9hYuUpRCOkgV2UF2DdHXtqHi3+BU/ecbz2zpHyf/720h48UbA3XOMYOX1sdM+dQ==" />
<entry key="REDIS_DATABASE" value="0" />
<entry key="REDIS_HOST" value="20.249.193.103" />
<entry key="REDIS_PASSWORD" value="Redis2025Dev!" />
@@ -0,0 +1,54 @@
package com.phonebill.user.config;
import com.phonebill.user.entity.AuthPermissionEntity;
import com.phonebill.user.enums.PermissionCode;
import com.phonebill.user.repository.AuthPermissionRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* 애플리케이션 시작 시 기본 데이터 초기화
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DataInitializer implements CommandLineRunner {
private final AuthPermissionRepository authPermissionRepository;
@Override
@Transactional
public void run(String... args) throws Exception {
initializePermissions();
}
/**
* 기본 권한 데이터 초기화
*/
private void initializePermissions() {
log.info("권한 데이터 초기화 시작");
for (PermissionCode permissionCode : PermissionCode.values()) {
// 이미 존재하는 권한인지 확인
if (!authPermissionRepository.existsByPermissionCodeAndIsActiveTrue(permissionCode.getCode())) {
AuthPermissionEntity permission = AuthPermissionEntity.builder()
.serviceCode("USER_SERVICE")
.permissionCode(permissionCode.getCode())
.permissionName(permissionCode.getCode())
.permissionDescription(permissionCode.getDescription())
.isActive(true)
.build();
authPermissionRepository.save(permission);
log.info("권한 생성: {}", permissionCode.getCode());
} else {
log.debug("권한 이미 존재: {}", permissionCode.getCode());
}
}
log.info("권한 데이터 초기화 완료");
}
}
@@ -42,8 +42,9 @@ public class JwtConfig {
*/
@Bean
public JwtTokenProvider jwtTokenProvider(
@Value("${security.jwt.secret:phonebill-jwt-secret-key-2025-dev}") String secret,
@Value("${security.jwt.access-token-expiration:3600}") long tokenValidityInSeconds) {
@Value("${jwt.secret}") String secret,
@Value("${jwt.access-token-validity}") long tokenValidityInMilliseconds) {
long tokenValidityInSeconds = tokenValidityInMilliseconds / 1000;
return new JwtTokenProvider(secret, tokenValidityInSeconds);
}
}
@@ -3,6 +3,7 @@ package com.phonebill.user.config;
import com.phonebill.common.security.JwtAuthenticationFilter;
import com.phonebill.common.security.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -28,7 +29,9 @@ import java.util.List;
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Value("${cors.allowed-origins")
private String allowedOrigins;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
@@ -46,8 +49,10 @@ public class SecurityConfig {
.authorizeHttpRequests(authz -> authz
// Public endpoints (인증 불필요)
.requestMatchers(
"/auth/login",
"/auth/refresh",
"/api/v1/auth/login",
"/api/v1/auth/register",
"/api/v1/auth/refresh",
"/api/v1/users",
"/actuator/health",
"/actuator/info",
"/actuator/prometheus",
@@ -102,10 +107,11 @@ public class SecurityConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 개발환경에서는 모든 Origin 허용, 운영환경에서는 특정 도메인만 허용
configuration.setAllowedOriginPatterns(List.of("*"));
// 환경변수에서 허용할 Origin 패턴 설정
String[] origins = allowedOrigins.split(",");
configuration.setAllowedOriginPatterns(Arrays.asList(origins));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
@@ -2,11 +2,14 @@ package com.phonebill.user.controller;
import com.phonebill.user.dto.*;
import com.phonebill.user.service.AuthService;
import com.phonebill.user.service.JwtService;
import com.phonebill.user.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -19,12 +22,14 @@ import org.springframework.web.bind.annotation.*;
*/
@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
@Tag(name = "Authentication", description = "인증 관련 API")
public class AuthController {
private final AuthService authService;
private final JwtService jwtService;
private final UserService userService;
/**
* 사용자 로그인
@@ -47,7 +52,10 @@ public class AuthController {
@Parameter(description = "로그인 요청 정보", required = true)
@Valid @RequestBody LoginRequest loginRequest
) {
log.info("로그인 요청: userId={}", loginRequest.getUserId());
log.info("로그인 요청 받음: userId={}, password존재={}, autoLogin={}",
loginRequest.getUserId(),
loginRequest.getPassword() != null,
loginRequest.getAutoLogin());
LoginResponse response = authService.login(loginRequest);
@@ -85,29 +93,43 @@ public class AuthController {
/**
* 로그아웃
* @param userId 사용자 ID
* @param refreshToken Refresh Token
* @param request HTTP 요청 (Authorization Header에서 JWT 토큰 추출)
* @return 로그아웃 결과
*/
@Operation(
summary = "사용자 로그아웃",
description = "현재 세션을 종료하고 Refresh Token을 무효화합니다."
description = "Authorization Header의 JWT 토큰에서 사용자 정보를 추출하여 현재 세션을 종료하고 Refresh Token을 무효화합니다."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "로그아웃 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
@ApiResponse(responseCode = "401", description = "유효하지 않은 토큰"),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@PostMapping("/logout")
public ResponseEntity<String> logout(
@Parameter(description = "사용자 ID", required = true)
@RequestParam String userId,
@Parameter(description = "Refresh Token", required = true)
@RequestParam String refreshToken
) {
public ResponseEntity<String> logout(HttpServletRequest request) {
// Authorization Header에서 JWT 토큰 추출
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return ResponseEntity.badRequest().body("Authorization Header가 필요합니다.");
}
String accessToken = authHeader.substring(7); // "Bearer " 제거
// JWT 유효성 확인 (AuthService에서 블랙리스트도 확인함)
if (!jwtService.validateToken(accessToken)) {
return ResponseEntity.badRequest().body("유효하지 않은 토큰입니다.");
}
// JWT에서 사용자 ID 추출
String userId = jwtService.getUserIdFromToken(accessToken);
if (userId == null) {
return ResponseEntity.badRequest().body("유효하지 않은 토큰입니다.");
}
log.info("로그아웃 요청: userId={}", userId);
authService.logout(userId, refreshToken);
// 해당 사용자의 모든 활성 세션 무효화 (Access Token 기반)
authService.logoutWithAccessToken(userId, accessToken);
log.info("로그아웃 성공: userId={}", userId);
return ResponseEntity.ok("로그아웃이 완료되었습니다.");
@@ -175,6 +197,62 @@ public class AuthController {
return ResponseEntity.ok("비밀번호가 성공적으로 변경되었습니다. 다시 로그인해 주세요.");
}
/**
* 계정 잠금 해제 (관리자용)
* @param userId 사용자 ID
* @return 처리 결과
*/
@Operation(
summary = "계정 잠금 해제",
description = "잠겨있는 사용자 계정의 잠금을 해제합니다. (관리자 권한 필요)"
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "잠금 해제 성공"),
@ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음"),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@PostMapping("/unlock/{userId}")
public ResponseEntity<String> unlockAccount(
@Parameter(description = "사용자 ID", required = true)
@PathVariable String userId
) {
log.info("계정 잠금 해제 요청: userId={}", userId);
userService.unlockAccount(userId);
log.info("계정 잠금 해제 성공: userId={}", userId);
return ResponseEntity.ok("계정 잠금이 성공적으로 해제되었습니다.");
}
/**
* 사용자 등록
* @param request 사용자 등록 요청
* @return 등록 결과
*/
@Operation(
summary = "사용자 등록",
description = "새로운 사용자를 등록하고 지정된 권한을 부여합니다."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "사용자 등록 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청 (입력값 검증 실패 또는 중복 데이터)"),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@PostMapping("/register")
public ResponseEntity<UserRegistrationResponse> registerUser(
@Parameter(description = "사용자 등록 요청 정보", required = true)
@Valid @RequestBody UserRegistrationRequest request
) {
log.info("사용자 등록 요청 받음: request={}", request);
log.info("상세 정보 - userId={}, customerId={}, lineNumber={}, userName={}",
request.getUserId(), request.getCustomerId(), request.getLineNumber(), request.getUserName());
UserRegistrationResponse response = userService.registerUser(request);
log.info("사용자 등록 API 처리 완료: userId={}", request.getUserId());
return ResponseEntity.ok(response);
}
/**
* 헬스 체크
* @return 서비스 상태
@@ -1,7 +1,8 @@
package com.phonebill.user.controller;
import com.phonebill.user.dto.*;
import com.phonebill.user.entity.AuthUserEntity;
import com.phonebill.user.dto.UserInfoResponse;
import com.phonebill.user.dto.PermissionsResponse;
import com.phonebill.user.enums.PermissionCode;
import com.phonebill.user.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -22,13 +23,35 @@ import java.util.List;
*/
@Slf4j
@RestController
@RequestMapping("/api/users")
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
@Tag(name = "User Management", description = "사용자 관리 API")
public class UserController {
private final UserService userService;
/**
* 모든 사용자 정보 조회
* @return 사용자 정보 목록
*/
@Operation(
summary = "모든 사용자 정보 조회",
description = "등록된 모든 사용자의 정보를 조회합니다."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "조회 성공"),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@GetMapping
public ResponseEntity<List<UserInfoResponse>> getAllUsers() {
log.info("모든 사용자 정보 조회 요청");
List<UserInfoResponse> response = userService.getAllUsers();
log.info("모든 사용자 정보 조회 성공: 사용자 수={}", response.size());
return ResponseEntity.ok(response);
}
/**
* 사용자 정보 조회
* @param userId 사용자 ID
@@ -110,93 +133,6 @@ public class UserController {
return ResponseEntity.ok(response);
}
/**
* 사용자 권한 목록 조회
* @param userId 사용자 ID
* @return 권한 목록
*/
@Operation(
summary = "사용자 권한 목록 조회",
description = "사용자가 보유한 모든 권한 목록을 조회합니다."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "조회 성공"),
@ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음"),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@GetMapping("/{userId}/permissions")
public ResponseEntity<PermissionsResponse> getUserPermissions(
@Parameter(description = "사용자 ID", required = true)
@PathVariable String userId
) {
log.info("사용자 권한 목록 조회 요청: userId={}", userId);
PermissionsResponse response = userService.getUserPermissions(userId);
log.info("사용자 권한 목록 조회 성공: userId={}, 권한 수={}", userId, response.getPermissions().size());
return ResponseEntity.ok(response);
}
/**
* 특정 권한 보유 여부 확인
* @param request 권한 확인 요청
* @return 권한 확인 결과
*/
@Operation(
summary = "특정 권한 보유 여부 확인",
description = "사용자가 특정 권한을 보유하고 있는지 확인합니다."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "확인 완료"),
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@PostMapping("/check-permission")
public ResponseEntity<PermissionCheckResponse> checkPermission(
@Parameter(description = "권한 확인 요청", required = true)
@Valid @RequestBody PermissionCheckRequest request
) {
log.info("권한 확인 요청: userId={}, permissionCode={}",
request.getUserId(), request.getPermissionCode());
PermissionCheckResponse response = userService.checkPermission(request);
log.info("권한 확인 완료: userId={}, permissionCode={}, hasPermission={}",
request.getUserId(), request.getPermissionCode(), response.getHasPermission());
return ResponseEntity.ok(response);
}
/**
* 서비스별 사용자 권한 조회
* @param userId 사용자 ID
* @param serviceCode 서비스 코드
* @return 서비스별 권한 목록
*/
@Operation(
summary = "서비스별 사용자 권한 조회",
description = "특정 서비스에 대한 사용자 권한 목록을 조회합니다."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "조회 성공"),
@ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음"),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@GetMapping("/{userId}/permissions/{serviceCode}")
public ResponseEntity<List<String>> getUserPermissionsByService(
@Parameter(description = "사용자 ID", required = true)
@PathVariable String userId,
@Parameter(description = "서비스 코드", required = true)
@PathVariable String serviceCode
) {
log.info("서비스별 사용자 권한 조회 요청: userId={}, serviceCode={}", userId, serviceCode);
List<String> permissions = userService.getUserPermissionsByService(userId, serviceCode);
log.info("서비스별 사용자 권한 조회 성공: userId={}, serviceCode={}, 권한 수={}",
userId, serviceCode, permissions.size());
return ResponseEntity.ok(permissions);
}
/**
* 권한 부여
* @param userId 사용자 ID
@@ -206,7 +142,12 @@ public class UserController {
*/
@Operation(
summary = "권한 부여",
description = "사용자에게 특정 권한을 부여합니다."
description = "사용자에게 특정 권한을 부여합니다.\n\n" +
"**사용 가능한 권한 코드:**\n" +
"- `BILL_INQUIRY`: 요금 조회 서비스 권한\n" +
"- `PRODUCT_CHANGE`: 상품 변경 서비스 권한\n" +
"- `ADMIN`: 관리자 권한\n" +
"- `USER_MANAGEMENT`: 사용자 관리 권한"
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "권한 부여 성공"),
@@ -218,7 +159,11 @@ public class UserController {
public ResponseEntity<String> grantPermission(
@Parameter(description = "사용자 ID", required = true)
@PathVariable String userId,
@Parameter(description = "권한 코드", required = true)
@Parameter(
description = "권한 코드",
required = true,
example = "BILL_INQUIRY"
)
@PathVariable String permissionCode,
@Parameter(description = "권한 부여자", required = true)
@RequestParam String grantedBy
@@ -226,6 +171,9 @@ public class UserController {
log.info("권한 부여 요청: userId={}, permissionCode={}, grantedBy={}",
userId, permissionCode, grantedBy);
// 권한 코드 유효성 검증
PermissionCode.fromCode(permissionCode);
userService.grantPermission(userId, permissionCode, grantedBy);
log.info("권한 부여 성공: userId={}, permissionCode={}", userId, permissionCode);
@@ -240,7 +188,12 @@ public class UserController {
*/
@Operation(
summary = "권한 철회",
description = "사용자의 특정 권한을 철회합니다."
description = "사용자의 특정 권한을 철회합니다.\n\n" +
"**사용 가능한 권한 코드:**\n" +
"- `BILL_INQUIRY`: 요금 조회 서비스 권한\n" +
"- `PRODUCT_CHANGE`: 상품 변경 서비스 권한\n" +
"- `ADMIN`: 관리자 권한\n" +
"- `USER_MANAGEMENT`: 사용자 관리 권한"
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "권한 철회 성공"),
@@ -252,128 +205,26 @@ public class UserController {
public ResponseEntity<String> revokePermission(
@Parameter(description = "사용자 ID", required = true)
@PathVariable String userId,
@Parameter(description = "권한 코드", required = true)
@Parameter(
description = "권한 코드",
required = true,
example = "BILL_INQUIRY"
)
@PathVariable String permissionCode
) {
log.info("권한 철회 요청: userId={}, permissionCode={}", userId, permissionCode);
// 권한 코드 유효성 검증
PermissionCode.fromCode(permissionCode);
userService.revokePermission(userId, permissionCode);
log.info("권한 철회 성공: userId={}, permissionCode={}", userId, permissionCode);
return ResponseEntity.ok("권한이 성공적으로 철회되었습니다.");
}
/**
* 계정 상태 조회
* @param userId 사용자 ID
* @return 계정 상태
*/
@Operation(
summary = "계정 상태 조회",
description = "사용자 계정의 현재 상태를 조회합니다."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "조회 성공"),
@ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음"),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@GetMapping("/{userId}/status")
public ResponseEntity<AuthUserEntity.AccountStatus> getAccountStatus(
@Parameter(description = "사용자 ID", required = true)
@PathVariable String userId
) {
log.info("계정 상태 조회 요청: userId={}", userId);
AuthUserEntity.AccountStatus status = userService.getAccountStatus(userId);
log.info("계정 상태 조회 성공: userId={}, status={}", userId, status);
return ResponseEntity.ok(status);
}
/**
* 계정 잠금 해제 (관리자용)
* @param userId 사용자 ID
* @return 처리 결과
*/
@Operation(
summary = "계정 잠금 해제",
description = "잠겨있는 사용자 계정의 잠금을 해제합니다. (관리자 권한 필요)"
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "잠금 해제 성공"),
@ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음"),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@PostMapping("/{userId}/unlock")
public ResponseEntity<String> unlockAccount(
@Parameter(description = "사용자 ID", required = true)
@PathVariable String userId
) {
log.info("계정 잠금 해제 요청: userId={}", userId);
userService.unlockAccount(userId);
log.info("계정 잠금 해제 성공: userId={}", userId);
return ResponseEntity.ok("계정 잠금이 성공적으로 해제되었습니다.");
}
/**
* 사용자 ID 존재 여부 확인
* @param userId 사용자 ID
* @return 존재 여부
*/
@Operation(
summary = "사용자 ID 존재 여부 확인",
description = "해당 사용자 ID가 시스템에 존재하는지 확인합니다."
)
@ApiResponse(responseCode = "200", description = "확인 완료")
@GetMapping("/{userId}/exists")
public ResponseEntity<Boolean> existsUserId(
@Parameter(description = "사용자 ID", required = true)
@PathVariable String userId
) {
log.info("사용자 ID 존재 여부 확인 요청: userId={}", userId);
boolean exists = userService.existsUserId(userId);
log.info("사용자 ID 존재 여부 확인 완료: userId={}, exists={}", userId, exists);
return ResponseEntity.ok(exists);
}
/**
* 고객 ID 존재 여부 확인
* @param customerId 고객 ID
* @return 존재 여부
*/
@Operation(
summary = "고객 ID 존재 여부 확인",
description = "해당 고객 ID가 시스템에 존재하는지 확인합니다."
)
@ApiResponse(responseCode = "200", description = "확인 완료")
@GetMapping("/customer/{customerId}/exists")
public ResponseEntity<Boolean> existsCustomerId(
@Parameter(description = "고객 ID", required = true)
@PathVariable String customerId
) {
log.info("고객 ID 존재 여부 확인 요청: customerId={}", customerId);
boolean exists = userService.existsCustomerId(customerId);
log.info("고객 ID 존재 여부 확인 완료: customerId={}, exists={}", customerId, exists);
return ResponseEntity.ok(exists);
}
/**
* 헬스 체크
* @return 서비스 상태
*/
@Operation(
summary = "사용자 서비스 헬스 체크",
description = "사용자 서비스의 상태를 확인합니다."
)
@ApiResponse(responseCode = "200", description = "서비스 정상")
@GetMapping("/health")
public ResponseEntity<String> healthCheck() {
return ResponseEntity.ok("User Service is running");
}
}
@@ -1,5 +1,6 @@
package com.phonebill.user.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
@@ -7,21 +8,25 @@ import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* 로그인 요청 DTO
*/
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LoginRequest {
@JsonProperty("userId")
@NotBlank(message = "사용자 ID는 필수입니다")
@Size(min = 3, max = 20, message = "사용자 ID는 3-20자 사이여야 합니다")
@Pattern(regexp = "^[a-zA-Z0-9_-]+$", message = "사용자 ID는 영문, 숫자, '_', '-'만 사용 가능합니다")
private String userId;
@JsonProperty("password")
@NotBlank(message = "비밀번호는 필수입니다")
@Size(min = 8, max = 50, message = "비밀번호는 8-50자 사이여야 합니다")
private String password;
@@ -1,15 +1,37 @@
package com.phonebill.user.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* 로그아웃 요청 DTO
*/
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LogoutRequest {
@JsonProperty("userId")
@NotBlank(message = "사용자 ID는 필수입니다")
private String userId;
@JsonProperty("refreshToken")
@NotBlank(message = "리프레시 토큰은 필수입니다")
private String refreshToken;
// 보안을 위해 toString에서 토큰 일부만 표시
@Override
public String toString() {
return "LogoutRequest{" +
"userId='" + userId + '\'' +
", refreshToken='" + (refreshToken != null ? refreshToken.substring(0, Math.min(refreshToken.length(), 10)) + "..." : "null") +
'}';
}
}
@@ -1,29 +0,0 @@
package com.phonebill.user.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 권한 확인 요청 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PermissionCheckRequest {
@NotBlank(message = "사용자 ID는 필수입니다")
private String userId;
@NotBlank(message = "권한 코드는 필수입니다")
private String permissionCode;
@NotBlank(message = "서비스 타입은 필수입니다")
@Pattern(regexp = "^(BILL_INQUIRY|PRODUCT_CHANGE)$",
message = "서비스 타입은 BILL_INQUIRY 또는 PRODUCT_CHANGE만 허용됩니다")
private String serviceType;
}
@@ -1,36 +0,0 @@
package com.phonebill.user.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 권한 확인 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PermissionCheckResponse {
private String userId;
private String permissionCode;
private String serviceType;
private Boolean hasPermission;
private String message;
private PermissionDetails permissionDetails;
/**
* 권한 상세 정보
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class PermissionDetails {
private String permission;
private String description;
private Boolean granted;
}
}
@@ -1,20 +1,24 @@
package com.phonebill.user.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* 토큰 갱신 요청 DTO
*/
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RefreshTokenRequest {
@JsonProperty("refreshToken")
@NotBlank(message = "리프레시 토큰은 필수입니다")
private String refreshToken;
@@ -22,8 +22,6 @@ public class UserInfoResponse {
private String customerId;
private String lineNumber;
private String userName;
private String phoneNumber;
private String email;
private String status;
private String accountStatus;
@@ -0,0 +1,65 @@
package com.phonebill.user.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 사용자 등록 요청 DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "사용자 등록 요청")
public class UserRegistrationRequest {
@JsonProperty("userId")
@Schema(description = "사용자 ID", example = "mvno001")
@NotBlank(message = "사용자 ID는 필수입니다")
@Size(min = 3, max = 20, message = "사용자 ID는 3자 이상 20자 이하여야 합니다")
@Pattern(regexp = "^[a-zA-Z0-9_-]+$", message = "사용자 ID는 영문, 숫자, _, - 만 사용 가능합니다")
private String userId;
@JsonProperty("customerId")
@Schema(description = "고객 ID", example = "CU202401001")
@NotBlank(message = "고객 ID는 필수입니다")
@Size(max = 20, message = "고객 ID는 20자 이하여야 합니다")
private String customerId;
@JsonProperty("lineNumber")
@Schema(description = "회선번호", example = "010-1234-5678")
@NotBlank(message = "회선번호는 필수입니다")
@Pattern(regexp = "^010-\\d{4}-\\d{4}$", message = "회선번호는 010-XXXX-XXXX 형식이어야 합니다")
private String lineNumber;
@JsonProperty("userName")
@Schema(description = "사용자 이름", example = "홍길동")
@NotBlank(message = "사용자 이름은 필수입니다")
@Size(max = 50, message = "사용자 이름은 50자 이하여야 합니다")
private String userName;
@JsonProperty("password")
@Schema(description = "비밀번호", example = "securePassword123!")
@NotBlank(message = "비밀번호는 필수입니다")
@Size(min = 8, max = 50, message = "비밀번호는 8자 이상 50자 이하여야 합니다")
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$",
message = "비밀번호는 대문자, 소문자, 숫자, 특수문자를 각각 최소 1개씩 포함해야 합니다"
)
private String password;
@JsonProperty("permissions")
@Schema(description = "권한 목록", example = "[\"BILL_INQUIRY\", \"PRODUCT_CHANGE\"]")
@NotEmpty(message = "권한은 최소 1개 이상 필요합니다")
private List<String> permissions;
}
@@ -0,0 +1,59 @@
package com.phonebill.user.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 사용자 등록 응답 DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "사용자 등록 응답")
public class UserRegistrationResponse {
@Schema(description = "응답 성공 여부", example = "true")
private boolean success;
@Schema(description = "응답 메시지", example = "사용자가 성공적으로 등록되었습니다")
private String message;
@Schema(description = "등록된 사용자 정보")
private UserData data;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "등록된 사용자 데이터")
public static class UserData {
@Schema(description = "사용자 ID", example = "mvno001")
private String userId;
@Schema(description = "고객 ID", example = "CU202401001")
private String customerId;
@Schema(description = "회선번호", example = "010-1234-5678")
private String lineNumber;
@Schema(description = "사용자 이름", example = "홍길동")
private String userName;
@Schema(description = "계정 상태", example = "ACTIVE")
private String accountStatus;
@Schema(description = "등록 시간", example = "2024-01-15T10:30:00")
private LocalDateTime createdAt;
@Schema(description = "부여된 권한 목록", example = "[\"BILL_INQUIRY\", \"PRODUCT_CHANGE\"]")
private List<String> permissions;
}
}
@@ -33,6 +33,9 @@ public class AuthUserEntity extends BaseTimeEntity {
@Column(name = "line_number", length = 20)
private String lineNumber;
@Column(name = "user_name", length = 100)
private String userName;
@Enumerated(EnumType.STRING)
@Column(name = "account_status", length = 20)
@Builder.Default
@@ -19,7 +19,8 @@ public class AuthUserSessionEntity extends BaseTimeEntity {
@Id
@Column(name = "session_id", length = 100)
private String sessionId;
@Builder.Default
private String sessionId = java.util.UUID.randomUUID().toString();
@Column(name = "user_id", nullable = false, length = 50)
private String userId;
@@ -0,0 +1,69 @@
package com.phonebill.user.enums;
/**
* 권한 코드 열거형
* 시스템에서 사용 가능한 모든 권한 코드를 정의
*/
public enum PermissionCode {
/**
* 요금 조회 서비스 권한
*/
BILL_INQUIRY("요금 조회 서비스 권한"),
/**
* 상품 변경 서비스 권한
*/
PRODUCT_CHANGE("상품 변경 서비스 권한"),
/**
* 관리자 권한
*/
ADMIN("관리자 권한"),
/**
* 사용자 관리 권한
*/
USER_MANAGEMENT("사용자 관리 권한");
private final String description;
PermissionCode(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
public String getCode() {
return this.name();
}
/**
* 권한 코드 문자열로부터 enum 객체를 찾는다
* @param code 권한 코드 문자열
* @return PermissionCode enum 객체
* @throws IllegalArgumentException 유효하지 않은 권한 코드인 경우
*/
public static PermissionCode fromCode(String code) {
try {
return PermissionCode.valueOf(code.toUpperCase());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("유효하지 않은 권한 코드입니다: " + code);
}
}
/**
* 모든 권한 코드 문자열을 배열로 반환
* @return 권한 코드 문자열 배열
*/
public static String[] getAllCodes() {
PermissionCode[] values = PermissionCode.values();
String[] codes = new String[values.length];
for (int i = 0; i < values.length; i++) {
codes[i] = values[i].name();
}
return codes;
}
}
@@ -157,6 +157,6 @@ public class UserServiceExceptionHandler {
*/
private String getRequestPath() {
// 실제 구현에서는 HttpServletRequest를 주입받아 사용
return "/api/auth";
return "/auth";
}
}
@@ -111,6 +111,11 @@ public interface AuthUserSessionRepository extends JpaRepository<AuthUserSession
*/
Optional<AuthUserSessionEntity> findByUserIdAndRefreshTokenAndIsActiveTrue(String userId, String refreshToken);
/**
* 사용자 ID와 세션 토큰으로 활성 세션 조회
*/
Optional<AuthUserSessionEntity> findByUserIdAndSessionTokenAndIsActiveTrue(String userId, String sessionToken);
/**
* 사용자의 모든 세션 비활성화
*/
@@ -16,6 +16,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
/**
@@ -33,6 +34,7 @@ public class AuthService {
private final JwtService jwtService;
private final JwtConfig jwtConfig;
private final PasswordEncoder passwordEncoder;
private final TokenBlacklistService tokenBlacklistService;
private static final int MAX_LOGIN_ATTEMPTS = 5;
private static final long LOCKOUT_DURATION = 30 * 60 * 1000L; // 30분
@@ -65,7 +67,7 @@ public class AuthService {
String refreshToken = jwtService.generateRefreshToken(user);
// 세션 저장
saveUserSession(user, refreshToken);
saveUserSession(user, accessToken, refreshToken);
log.info("사용자 로그인 성공: userId={}", user.getUserId());
@@ -89,7 +91,12 @@ public class AuthService {
public RefreshTokenResponse refreshToken(RefreshTokenRequest request) {
String refreshToken = request.getRefreshToken();
// Refresh Token 유효성 검증
// 1. 블랙리스트 확인
if (tokenBlacklistService.isBlacklisted(refreshToken)) {
throw InvalidTokenException.invalid();
}
// 2. JWT 토큰 유효성 검증
if (!jwtService.validateToken(refreshToken) || !jwtService.isRefreshToken(refreshToken)) {
throw InvalidTokenException.invalid();
}
@@ -119,7 +126,7 @@ public class AuthService {
// 기존 세션 비활성화 및 새 세션 생성
session.deactivate();
saveUserSession(user, newRefreshToken);
saveUserSession(user, newAccessToken, newRefreshToken);
log.info("토큰 갱신 성공: userId={}", userId);
@@ -132,7 +139,7 @@ public class AuthService {
}
/**
* 로그아웃
* 로그아웃 (Refresh Token 기반)
* @param userId 사용자 ID
* @param refreshToken Refresh Token
*/
@@ -148,12 +155,65 @@ public class AuthService {
}
/**
* 토큰 검증
* 로그아웃 (Access Token 기반)
* @param userId 사용자 ID
* @param accessToken Access Token
*/
@Transactional
public void logoutWithAccessToken(String userId, String accessToken) {
// 1. Access Token을 블랙리스트에 추가 (즉시 무효화)
tokenBlacklistService.addToBlacklist(accessToken, "LOGOUT");
// 2. Access Token과 일치하는 활성 세션 찾아서 비활성화
Optional<AuthUserSessionEntity> sessionOpt = authUserSessionRepository
.findByUserIdAndSessionTokenAndIsActiveTrue(userId, accessToken);
if (sessionOpt.isPresent()) {
AuthUserSessionEntity session = sessionOpt.get();
session.deactivate();
// 3. 해당 세션의 Refresh Token도 블랙리스트에 추가
if (session.getRefreshToken() != null) {
tokenBlacklistService.addToBlacklist(session.getRefreshToken(), "LOGOUT");
}
log.info("Access Token 기반 로그아웃 완료: userId={}", userId);
} else {
// 세션이 없는 경우, 해당 사용자의 모든 활성 세션 무효화
List<AuthUserSessionEntity> activeSessions = authUserSessionRepository
.findByUserId(userId)
.stream()
.filter(AuthUserSessionEntity::isActive)
.toList();
// 모든 활성 세션의 토큰들을 블랙리스트에 추가
for (AuthUserSessionEntity session : activeSessions) {
if (session.getSessionToken() != null) {
tokenBlacklistService.addToBlacklist(session.getSessionToken(), "LOGOUT_ALL");
}
if (session.getRefreshToken() != null) {
tokenBlacklistService.addToBlacklist(session.getRefreshToken(), "LOGOUT_ALL");
}
}
authUserSessionRepository.deactivateAllUserSessions(userId);
log.info("모든 세션 무효화 로그아웃 완료: userId={}, 무효화된 세션 수={}", userId, activeSessions.size());
}
}
/**
* 토큰 검증 (블랙리스트 포함)
* @param token 검증할 토큰
* @return 토큰 검증 결과
*/
public TokenVerifyResponse verifyToken(String token) {
try {
// 1. 블랙리스트 확인
if (tokenBlacklistService.isBlacklisted(token)) {
return TokenVerifyResponse.invalid();
}
// 2. JWT 유효성 확인
if (!jwtService.validateToken(token)) {
return TokenVerifyResponse.invalid();
}
@@ -225,11 +285,13 @@ public class AuthService {
/**
* 사용자 세션 저장
* @param user 사용자 정보
* @param sessionToken Session Token (Access Token)
* @param refreshToken Refresh Token
*/
private void saveUserSession(AuthUserEntity user, String refreshToken) {
private void saveUserSession(AuthUserEntity user, String sessionToken, String refreshToken) {
AuthUserSessionEntity session = AuthUserSessionEntity.builder()
.userId(user.getUserId())
.sessionToken(sessionToken)
.refreshToken(refreshToken)
.expiresAt(jwtService.getExpirationDateFromToken(refreshToken))
.isActive(true)
@@ -123,7 +123,7 @@ public class JwtService {
}
/**
* 토큰 유효성 검증
* 토큰 유효성 검증 (JWT 자체만 검증, 블랙리스트는 AuthService에서 확인)
* @param token JWT 토큰
* @return 유효성 여부
*/
@@ -0,0 +1,116 @@
package com.phonebill.user.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.concurrent.TimeUnit;
/**
* JWT 토큰 블랙리스트 서비스
* Redis를 사용해서 무효화된 토큰을 관리
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TokenBlacklistService {
private final RedisTemplate<String, String> redisTemplate;
private final JwtService jwtService;
private static final String BLACKLIST_PREFIX = "blacklist:";
/**
* 토큰을 블랙리스트에 추가
* @param token JWT 토큰
* @param reason 무효화 사유
*/
public void addToBlacklist(String token, String reason) {
try {
// JWT에서 만료시간 추출
LocalDateTime expiresAt = jwtService.getExpirationDateFromToken(token);
if (expiresAt == null) {
log.warn("토큰에서 만료시간을 추출할 수 없음: {}", token.substring(0, Math.min(token.length(), 20)));
return;
}
// 현재시간부터 토큰 만료시간까지의 TTL 계산
long ttlSeconds = expiresAt.atZone(ZoneId.systemDefault()).toEpochSecond() -
LocalDateTime.now().atZone(ZoneId.systemDefault()).toEpochSecond();
// TTL이 양수인 경우만 블랙리스트에 추가 (이미 만료된 토큰은 추가하지 않음)
if (ttlSeconds > 0) {
String key = BLACKLIST_PREFIX + token;
redisTemplate.opsForValue().set(key, reason, ttlSeconds, TimeUnit.SECONDS);
log.info("토큰이 블랙리스트에 추가됨: reason={}, ttl={}초", reason, ttlSeconds);
} else {
log.info("이미 만료된 토큰이므로 블랙리스트에 추가하지 않음");
}
} catch (Exception e) {
log.error("블랙리스트 추가 실패: {}", e.getMessage(), e);
}
}
/**
* 토큰이 블랙리스트에 있는지 확인
* @param token JWT 토큰
* @return 블랙리스트 여부
*/
public boolean isBlacklisted(String token) {
try {
String key = BLACKLIST_PREFIX + token;
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
} catch (Exception e) {
log.error("블랙리스트 확인 실패: {}", e.getMessage(), e);
// Redis 오류 시 안전하게 false 반환 (서비스 중단 방지)
return false;
}
}
/**
* 사용자의 모든 토큰을 블랙리스트에 추가
* @param userId 사용자 ID
* @param reason 무효화 사유
*/
public void addUserTokensToBlacklist(String userId, String reason) {
try {
// 패턴으로 해당 사용자의 모든 토큰을 찾을 수는 없으므로,
// 실제로는 세션 테이블에서 활성 토큰을 조회해서 블랙리스트에 추가해야 함
log.info("사용자 {} 의 모든 토큰 무효화: {}", userId, reason);
} catch (Exception e) {
log.error("사용자 토큰 블랙리스트 추가 실패: userId={}, error={}", userId, e.getMessage(), e);
}
}
/**
* 블랙리스트에서 토큰 제거 (관리용)
* @param token JWT 토큰
*/
public void removeFromBlacklist(String token) {
try {
String key = BLACKLIST_PREFIX + token;
redisTemplate.delete(key);
log.info("토큰이 블랙리스트에서 제거됨");
} catch (Exception e) {
log.error("블랙리스트 제거 실패: {}", e.getMessage(), e);
}
}
/**
* 블랙리스트 통계 조회
* @return 블랙리스트 토큰 수
*/
public long getBlacklistCount() {
try {
return redisTemplate.keys(BLACKLIST_PREFIX + "*").size();
} catch (Exception e) {
log.error("블랙리스트 통계 조회 실패: {}", e.getMessage(), e);
return 0;
}
}
}
@@ -1,19 +1,25 @@
package com.phonebill.user.service;
import com.phonebill.user.dto.*;
import com.phonebill.user.dto.UserInfoResponse;
import com.phonebill.user.dto.UserRegistrationRequest;
import com.phonebill.user.dto.UserRegistrationResponse;
import com.phonebill.user.entity.AuthUserEntity;
import com.phonebill.user.entity.AuthPermissionEntity;
import com.phonebill.user.entity.AuthUserPermissionEntity;
import com.phonebill.user.exception.UserNotFoundException;
import com.phonebill.user.enums.PermissionCode;
import com.phonebill.user.repository.AuthUserRepository;
import com.phonebill.user.repository.AuthPermissionRepository;
import com.phonebill.user.repository.AuthUserPermissionRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -30,6 +36,33 @@ public class UserService {
private final AuthUserRepository authUserRepository;
private final AuthPermissionRepository authPermissionRepository;
private final AuthUserPermissionRepository authUserPermissionRepository;
private final PasswordEncoder passwordEncoder;
/**
* 모든 사용자 정보 조회
* @return 사용자 정보 목록
*/
public List<UserInfoResponse> getAllUsers() {
List<AuthUserEntity> users = authUserRepository.findAll();
return users.stream()
.map(user -> {
// 사용자 권한 목록 조회
List<String> permissions = authUserPermissionRepository.findPermissionCodesByUserId(user.getUserId());
return UserInfoResponse.builder()
.userId(user.getUserId())
.customerId(user.getCustomerId())
.lineNumber(user.getLineNumber())
.userName(user.getUserName())
.accountStatus(user.getAccountStatus().name())
.lastLoginAt(user.getLastLoginAt())
.lastPasswordChangedAt(user.getLastPasswordChangedAt())
.permissions(permissions)
.build();
})
.collect(Collectors.toList());
}
/**
* 사용자 정보 조회
@@ -40,13 +73,18 @@ public class UserService {
AuthUserEntity user = authUserRepository.findById(userId)
.orElseThrow(() -> UserNotFoundException.byUserId(userId));
// 사용자 권한 목록 조회
List<String> permissions = authUserPermissionRepository.findPermissionCodesByUserId(userId);
return UserInfoResponse.builder()
.userId(user.getUserId())
.customerId(user.getCustomerId())
.lineNumber(user.getLineNumber())
.userName(user.getUserName())
.accountStatus(user.getAccountStatus().name())
.lastLoginAt(user.getLastLoginAt())
.lastPasswordChangedAt(user.getLastPasswordChangedAt())
.permissions(permissions)
.build();
}
@@ -59,13 +97,18 @@ public class UserService {
AuthUserEntity user = authUserRepository.findByCustomerId(customerId)
.orElseThrow(() -> UserNotFoundException.byCustomerId(customerId));
// 사용자 권한 목록 조회
List<String> permissions = authUserPermissionRepository.findPermissionCodesByUserId(user.getUserId());
return UserInfoResponse.builder()
.userId(user.getUserId())
.customerId(user.getCustomerId())
.lineNumber(user.getLineNumber())
.userName(user.getUserName())
.accountStatus(user.getAccountStatus().name())
.lastLoginAt(user.getLastLoginAt())
.lastPasswordChangedAt(user.getLastPasswordChangedAt())
.permissions(permissions)
.build();
}
@@ -78,116 +121,21 @@ public class UserService {
AuthUserEntity user = authUserRepository.findByLineNumber(lineNumber)
.orElseThrow(() -> UserNotFoundException.byLineNumber(lineNumber));
// 사용자 권한 목록 조회
List<String> permissions = authUserPermissionRepository.findPermissionCodesByUserId(user.getUserId());
return UserInfoResponse.builder()
.userId(user.getUserId())
.customerId(user.getCustomerId())
.lineNumber(user.getLineNumber())
.userName(user.getUserName())
.accountStatus(user.getAccountStatus().name())
.lastLoginAt(user.getLastLoginAt())
.lastPasswordChangedAt(user.getLastPasswordChangedAt())
.build();
}
/**
* 사용자 권한 목록 조회
* @param userId 사용자 ID
* @return 권한 목록
*/
public PermissionsResponse getUserPermissions(String userId) {
// 사용자 존재 확인
if (!authUserRepository.existsByUserId(userId)) {
throw UserNotFoundException.byUserId(userId);
}
// 사용자가 보유한 권한 코드 목록 조회
List<String> permissionCodes = authUserPermissionRepository.findPermissionCodesByUserId(userId);
// 권한 코드를 Permission 객체로 변환
List<PermissionsResponse.Permission> permissions = permissionCodes.stream()
.map(code -> PermissionsResponse.Permission.builder()
.permission(code)
.description(getPermissionDescription(code))
.granted(true)
.build())
.collect(Collectors.toList());
return PermissionsResponse.builder()
.userId(userId)
.permissions(permissions)
.build();
}
/**
* 특정 권한 보유 여부 확인
* @param request 권한 확인 요청
* @return 권한 확인 결과
*/
public PermissionCheckResponse checkPermission(PermissionCheckRequest request) {
String userId = request.getUserId();
String permissionCode = request.getPermissionCode();
// 사용자 존재 확인
if (!authUserRepository.existsByUserId(userId)) {
return PermissionCheckResponse.builder()
.userId(userId)
.permissionCode(permissionCode)
.hasPermission(false)
.message("사용자를 찾을 수 없습니다.")
.build();
}
// 권한 존재 확인
Optional<AuthPermissionEntity> permissionOpt =
authPermissionRepository.findByPermissionCodeAndIsActiveTrue(permissionCode);
if (permissionOpt.isEmpty()) {
return PermissionCheckResponse.builder()
.userId(userId)
.permissionCode(permissionCode)
.hasPermission(false)
.message("존재하지 않는 권한입니다.")
.build();
}
AuthPermissionEntity permission = permissionOpt.get();
boolean hasPermission = authUserPermissionRepository.hasPermission(userId, permission.getPermissionId());
return PermissionCheckResponse.builder()
.userId(userId)
.permissionCode(permissionCode)
.hasPermission(hasPermission)
.message(hasPermission ? "권한이 있습니다." : "권한이 없습니다.")
.build();
}
/**
* 서비스별 사용자 권한 조회
* @param userId 사용자 ID
* @param serviceCode 서비스 코드
* @return 서비스별 권한 목록
*/
public List<String> getUserPermissionsByService(String userId, String serviceCode) {
// 사용자 존재 확인
if (!authUserRepository.existsByUserId(userId)) {
throw UserNotFoundException.byUserId(userId);
}
// 서비스별 사용자 권한 조회
List<AuthUserPermissionEntity> userPermissions =
authUserPermissionRepository.findUserPermissionsByService(userId, serviceCode);
// 권한 코드 목록으로 변환
return userPermissions.stream()
.map(up -> {
// 권한 정보 조회
Optional<AuthPermissionEntity> permissionOpt =
authPermissionRepository.findById(up.getPermissionId());
return permissionOpt.map(AuthPermissionEntity::getPermissionCode).orElse(null);
})
.filter(permissionCode -> permissionCode != null)
.collect(Collectors.toList());
}
/**
* 권한 부여
* @param userId 사용자 ID
@@ -252,35 +200,8 @@ public class UserService {
log.info("권한 철회 완료: userId={}, permissionCode={}", userId, permissionCode);
}
/**
* 사용자 ID 존재 여부 확인
* @param userId 사용자 ID
* @return 존재 여부
*/
public boolean existsUserId(String userId) {
return authUserRepository.existsByUserId(userId);
}
/**
* 고객 ID 존재 여부 확인
* @param customerId 고객 ID
* @return 존재 여부
*/
public boolean existsCustomerId(String customerId) {
return authUserRepository.existsByCustomerId(customerId);
}
/**
* 계정 상태 확인
* @param userId 사용자 ID
* @return 계정 상태 정보
*/
public AuthUserEntity.AccountStatus getAccountStatus(String userId) {
AuthUserEntity user = authUserRepository.findById(userId)
.orElseThrow(() -> UserNotFoundException.byUserId(userId));
return user.getAccountStatus();
}
/**
* 계정 활성 상태 확인
@@ -309,6 +230,128 @@ public class UserService {
log.info("계정 잠금 해제: userId={}", userId);
}
/**
* 사용자 등록
* @param request 사용자 등록 요청
* @return 등록된 사용자 정보
*/
@Transactional
public UserRegistrationResponse registerUser(UserRegistrationRequest request) {
log.info("사용자 등록 요청: userId={}, customerId={}", request.getUserId(), request.getCustomerId());
// 중복 검사
validateUserUniqueness(request);
// 권한 코드 유효성 검증
validatePermissionCodes(request.getPermissions());
// 사용자 엔티티 생성
AuthUserEntity user = createUserEntity(request);
// 사용자 저장
AuthUserEntity savedUser = authUserRepository.save(user);
// 권한 부여
grantUserPermissions(savedUser.getUserId(), request.getPermissions());
// 응답 생성
UserRegistrationResponse response = buildRegistrationResponse(savedUser, request.getPermissions(), request.getUserName());
log.info("사용자 등록 완료: userId={}", savedUser.getUserId());
return response;
}
/**
* 사용자 유니크 필드 중복 검사
*/
private void validateUserUniqueness(UserRegistrationRequest request) {
// 사용자 ID 중복 확인
if (authUserRepository.existsByUserId(request.getUserId())) {
throw new RuntimeException("이미 존재하는 사용자 ID입니다: " + request.getUserId());
}
// 고객 ID 중복 확인
if (authUserRepository.existsByCustomerId(request.getCustomerId())) {
throw new RuntimeException("이미 존재하는 고객 ID입니다: " + request.getCustomerId());
}
// 회선번호 중복 확인 - lineNumber로 사용자 검색해서 존재 여부 확인
if (authUserRepository.findByLineNumber(request.getLineNumber()).isPresent()) {
throw new RuntimeException("이미 존재하는 회선번호입니다: " + request.getLineNumber());
}
}
/**
* 권한 코드 유효성 검증
*/
private void validatePermissionCodes(List<String> permissionCodes) {
for (String code : permissionCodes) {
try {
PermissionCode.fromCode(code);
} catch (IllegalArgumentException e) {
throw new RuntimeException("유효하지 않은 권한 코드입니다: " + code);
}
}
}
/**
* 사용자 엔티티 생성
*/
private AuthUserEntity createUserEntity(UserRegistrationRequest request) {
// Salt 생성 (UUID 기반)
String salt = java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 16);
// password + salt 결합 후 해시
String saltedPassword = request.getPassword() + salt;
String hashedPassword = passwordEncoder.encode(saltedPassword);
return AuthUserEntity.builder()
.userId(request.getUserId())
.customerId(request.getCustomerId())
.lineNumber(request.getLineNumber())
.userName(request.getUserName())
.passwordHash(hashedPassword)
.passwordSalt(salt)
.accountStatus(AuthUserEntity.AccountStatus.ACTIVE)
.failedLoginCount(0)
.build();
}
/**
* 사용자에게 권한 부여
*/
private void grantUserPermissions(String userId, List<String> permissionCodes) {
for (String permissionCode : permissionCodes) {
try {
grantPermission(userId, permissionCode, "SYSTEM");
} catch (Exception e) {
log.error("권한 부여 실패: userId={}, permissionCode={}", userId, permissionCode, e);
throw new RuntimeException("권한 부여 중 오류가 발생했습니다: " + permissionCode);
}
}
}
/**
* 사용자 등록 응답 생성
*/
private UserRegistrationResponse buildRegistrationResponse(AuthUserEntity user, List<String> permissions, String userName) {
UserRegistrationResponse.UserData userData = UserRegistrationResponse.UserData.builder()
.userId(user.getUserId())
.customerId(user.getCustomerId())
.lineNumber(user.getLineNumber())
.userName(userName)
.accountStatus(user.getAccountStatus().name())
.createdAt(user.getCreatedAt())
.permissions(permissions)
.build();
return UserRegistrationResponse.builder()
.success(true)
.message("사용자가 성공적으로 등록되었습니다.")
.data(userData)
.build();
}
/**
* 권한 코드에 대한 설명 조회
* @param permissionCode 권한 코드
@@ -1,83 +1,4 @@
spring:
datasource:
url: jdbc:${DB_KIND:postgresql}://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:phonebill_auth}
username: ${DB_USERNAME:phonebill_user}
password: ${DB_PASSWORD:phonebill_pass}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000
# JPA 설정
jpa:
show-sql: ${SHOW_SQL:true}
properties:
hibernate:
format_sql: true
use_sql_comments: true
hibernate:
ddl-auto: ${DDL_AUTO:update}
# Redis 설정
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
timeout: 2000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1ms
database: ${REDIS_DATABASE:0}
# 개발환경 로깅 설정 (더 상세한 로그)
logging:
level:
com.phonebill: DEBUG
org.springframework.security: DEBUG
org.springframework.data.redis: DEBUG
org.springframework.cache: DEBUG
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql: TRACE
org.springframework.web.filter.CommonsRequestLoggingFilter: DEBUG
org.springframework.transaction: DEBUG
pattern:
console: "%d{HH:mm:ss.SSS} [%thread] %-5level [%logger{36}] - %msg%n"
# 개발환경 액추에이터 설정 (모든 엔드포인트 노출)
management:
endpoints:
web:
exposure:
include: "*"
endpoint:
health:
show-details: always
show-components: always
# 개발용 CORS 설정
cors:
allowed-origins:
- "http://localhost:3000"
- "http://localhost:3001"
- "http://127.0.0.1:3000"
allowed-methods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
allowed-headers: "*"
allow-credentials: true
max-age: 3600
# 개발환경 보안 설정 (덜 엄격)
auth:
password:
bcrypt-strength: 10 # 개발환경에서는 빠른 처리를 위해 낮춤
@@ -1,120 +1,7 @@
spring:
# 운영환경 데이터소스 설정
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:phonebill_auth}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 50
minimum-idle: 10
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 0 # 운영환경에서는 비활성화
# 운영환경 JPA 설정
jpa:
show-sql: false
properties:
hibernate:
format_sql: false
use_sql_comments: false
generate_statistics: false
jdbc:
batch_size: 50
order_inserts: true
order_updates: true
hibernate:
ddl-auto: validate # 운영환경에서는 스키마 변경 금지
# 운영환경 Redis 설정 (클러스터 구성)
data:
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD}
ssl: true # 운영환경에서는 SSL 활성화
timeout: 2000ms
lettuce:
pool:
max-active: 20
max-idle: 10
min-idle: 5
max-wait: -1ms
cluster:
refresh:
adaptive: true
period: 30s
database: 0
# 운영환경 로깅 설정 (최소 로그)
logging:
level:
com.phonebill: INFO
org.springframework.security: WARN
org.hibernate.SQL: WARN
org.springframework.web: WARN
org.springframework.data: WARN
org.springframework.cache: WARN
org.springframework.transaction: WARN
org.springframework.boot.actuate: WARN
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%logger{36}] [%X{traceId:-},%X{spanId:-}] - %msg%n"
file:
name: /var/log/phonebill/user-service.log
max-size: 100MB
max-history: 30
total-size-cap: 3GB
# 운영환경 액추에이터 설정 (보안 강화)
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: never
show-components: never
security:
enabled: true
# 운영환경 JWT 토큰 설정
jwt:
secret: ${JWT_SECRET} # 환경변수에서 필수로 받아옴
access-token-validity: 1800000 # 30분
refresh-token-validity: 86400000 # 24시간
# 운영환경 보안 설정 (엄격)
auth:
login:
max-failed-attempts: 3 # 운영환경에서는 더 엄격
lockout-duration: 3600000 # 1시간
password:
bcrypt-strength: 12
# 운영환경 성능 튜닝 설정
server:
tomcat:
threads:
max: 200
min-spare: 10
max-connections: 8192
accept-count: 100
connection-timeout: 30000
max-http-post-size: 2MB
# 운영환경 외부 서비스 연동 설정
external:
services:
bill-inquiry:
base-url: ${BILL_INQUIRY_URL}
timeout: 3000 # 운영환경에서는 더 짧은 타임아웃
product-change:
base-url: ${PRODUCT_CHANGE_URL}
timeout: 3000
# 운영환경 모니터링 설정
metrics:
export:
+42 -30
View File
@@ -4,18 +4,44 @@ spring:
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
# JPA 공통 설정
datasource:
url: jdbc:${DB_KIND:postgresql}://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:phonebill_auth}
username: ${DB_USERNAME:phonebill_user}
password: ${DB_PASSWORD:phonebill_pass}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000
# JPA 설정
jpa:
open-in-view: false
show-sql: false
show-sql: ${SHOW_SQL:true}
properties:
hibernate:
format_sql: true
default_batch_fetch_size: 100
use_sql_comments: true
hibernate:
ddl-auto: ${DDL_AUTO:validate}
ddl-auto: ${DDL_AUTO:update}
# Redis 설정
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
timeout: 2000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1ms
database: ${REDIS_DATABASE:0}
# Jackson 설정
jackson:
property-naming-strategy: SNAKE_CASE
@@ -32,23 +58,19 @@ spring:
# 서버 설정
server:
port: ${SERVER_PORT:8081}
# CORS
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000}
# JWT 토큰 설정
jwt:
secret: ${JWT_SECRET:Y2xhdWRlLWNvZGUtcGhvbmViaWxsLXNlY3JldC1rZXktZm9yLWF1dGgtc2VydmljZQ==}
access-token-validity: 1800000 # 30분 (milliseconds)
refresh-token-validity: 86400000 # 24시간 (milliseconds)
issuer: phonebill-auth-service
secret: ${JWT_SECRET:}
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800000}
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400000}
# 로깅 설정
logging:
level:
root: ${LOG_LEVEL_ROOT:INFO}
com.phonebill: ${LOG_LEVEL_APP:DEBUG}
org.springframework.security: DEBUG
org.springframework.web: INFO
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql: TRACE
file:
name: logs/user-service.log
@@ -94,13 +116,3 @@ auth:
auto-login-timeout: 86400000 # 24시간 (milliseconds)
password:
bcrypt-strength: 12
# 외부 서비스 연동 설정 (향후 확장용)
external:
services:
bill-inquiry:
base-url: ${BILL_INQUIRY_URL:http://localhost:8082}
timeout: 5000
product-change:
base-url: ${PRODUCT_CHANGE_URL:http://localhost:8083}
timeout: 5000