This commit is contained in:
hiondal
2025-09-09 01:12:14 +09:00
parent 7ec8a682c6
commit b489c73201
276 changed files with 43859 additions and 98 deletions
+48
View File
@@ -0,0 +1,48 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="user-service" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="env">
<map>
<entry key="BILL_INQUIRY_URL" value="http://localhost:8082" />
<entry key="DB_HOST" value="20.249.70.6" />
<entry key="DB_KIND" value="postgresql" />
<entry key="DB_NAME" value="phonebill_auth" />
<entry key="DB_PASSWORD" value="AuthUser2025!" />
<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="REDIS_DATABASE" value="0" />
<entry key="REDIS_HOST" value="20.249.193.103" />
<entry key="REDIS_PASSWORD" value="Redis2025Dev!" />
<entry key="REDIS_PORT" value="6379" />
<entry key="SERVER_PORT" value="8081" />
<entry key="SHOW_SQL" value="true" />
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
</map>
</option>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="user-service:bootRun" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>
+41
View File
@@ -0,0 +1,41 @@
// user-service 모듈
// 루트 build.gradle의 subprojects 블록에서 공통 설정 적용됨
dependencies {
// Common module dependency
implementation project(':common')
// Database (user service specific)
runtimeOnly 'org.postgresql:postgresql'
// Redis (user service specific)
implementation 'redis.clients:jedis'
// BCrypt for password hashing
implementation 'org.springframework.security:spring-security-crypto'
// Micrometer for metrics
implementation 'io.micrometer:micrometer-registry-prometheus'
// Test dependencies (user service specific)
testImplementation 'org.testcontainers:postgresql'
testImplementation 'com.h2database:h2'
testImplementation 'it.ozimov:embedded-redis:0.7.3'
}
// 추가 테스트 설정 (루트에서 기본 설정됨)
// JAR 파일명 설정
jar {
archiveBaseName = 'user-service'
enabled = false
}
bootJar {
archiveBaseName = 'user-service'
}
// Spring Boot 실행 설정
springBoot {
mainClass = 'com.phonebill.user.UserServiceApplication'
}
@@ -0,0 +1,31 @@
package com.phonebill.user;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.ComponentScan;
import com.phonebill.user.config.AuthConfig;
import com.phonebill.user.config.JwtConfig;
/**
* User Service (Auth Service) 메인 애플리케이션
*
* 주요 기능:
* - 사용자 인증/인가 (JWT 기반)
* - 사용자 세션 관리 (Redis)
* - 로그인/로그아웃 처리
* - 권한 관리 및 검증
* - 계정 잠금/해제 관리
*/
@SpringBootApplication
@EnableCaching
@EnableConfigurationProperties({JwtConfig.class, AuthConfig.class})
@ComponentScan(basePackages = {"com.phonebill.user", "com.phonebill.common"})
public class UserServiceApplication {
public static void main(String[] args) {
SpringApplication.run(UserServiceApplication.class, args);
}
}
@@ -0,0 +1,53 @@
package com.phonebill.user.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import lombok.Getter;
import lombok.Setter;
/**
* Auth Service 특화 설정 프로퍼티
*/
@Configuration
@ConfigurationProperties(prefix = "auth")
@Getter
@Setter
public class AuthConfig {
private Login login = new Login();
private Session session = new Session();
private Password password = new Password();
@Getter
@Setter
public static class Login {
private int maxFailedAttempts = 5;
private long lockoutDuration = 1800000; // 30분 (milliseconds)
public int getLockoutDurationInSeconds() {
return (int) (lockoutDuration / 1000);
}
}
@Getter
@Setter
public static class Session {
private long defaultTimeout = 1800000; // 30분 (milliseconds)
private long autoLoginTimeout = 86400000; // 24시간 (milliseconds)
public int getDefaultTimeoutInSeconds() {
return (int) (defaultTimeout / 1000);
}
public int getAutoLoginTimeoutInSeconds() {
return (int) (autoLoginTimeout / 1000);
}
}
@Getter
@Setter
public static class Password {
private int bcryptStrength = 12;
}
}
@@ -0,0 +1,49 @@
package com.phonebill.user.config;
import com.phonebill.common.security.JwtTokenProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import lombok.Getter;
import lombok.Setter;
/**
* JWT 설정 프로퍼티
*/
@Configuration
@ConfigurationProperties(prefix = "jwt")
@Getter
@Setter
public class JwtConfig {
private String secret;
private long accessTokenValidity = 1800000; // 30분 (milliseconds)
private long refreshTokenValidity = 86400000; // 24시간 (milliseconds)
private String issuer = "phonebill-auth-service";
/**
* Access Token 만료 시간 (초 단위)
*/
public int getAccessTokenValidityInSeconds() {
return (int) (accessTokenValidity / 1000);
}
/**
* Refresh Token 만료 시간 (초 단위)
*/
public int getRefreshTokenValidityInSeconds() {
return (int) (refreshTokenValidity / 1000);
}
/**
* JwtTokenProvider 빈 정의
*/
@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) {
return new JwtTokenProvider(secret, tokenValidityInSeconds);
}
}
@@ -0,0 +1,115 @@
package com.phonebill.user.config;
import com.phonebill.common.security.JwtAuthenticationFilter;
import com.phonebill.common.security.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
/**
* Spring Security 설정
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// CSRF 비활성화 (JWT 사용으로 불필요)
.csrf(csrf -> csrf.disable())
// CORS 설정
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 세션 비활성화 (JWT 기반 Stateless)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 권한 설정
.authorizeHttpRequests(authz -> authz
// Public endpoints (인증 불필요)
.requestMatchers(
"/auth/login",
"/auth/refresh",
"/actuator/health",
"/actuator/info",
"/actuator/prometheus",
"/v3/api-docs/**",
"/swagger-ui/**",
"/swagger-ui.html"
).permitAll()
// Protected endpoints (인증 필요)
.requestMatchers("/auth/**").authenticated()
// Actuator endpoints (관리용)
.requestMatchers("/actuator/**").hasRole("ADMIN")
// 나머지 모든 요청 인증 필요
.anyRequest().authenticated()
)
// JWT 필터 추가
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
// Exception 처리
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"success\":false,\"error\":{\"code\":\"UNAUTHORIZED\",\"message\":\"인증이 필요합니다.\",\"details\":\"유효한 토큰이 필요합니다.\"}}");
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setStatus(403);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"success\":false,\"error\":{\"code\":\"ACCESS_DENIED\",\"message\":\"접근이 거부되었습니다.\",\"details\":\"권한이 부족합니다.\"}}");
})
);
return http.build();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(jwtTokenProvider);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // 기본 설정에서 강도 12 사용
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 개발환경에서는 모든 Origin 허용, 운영환경에서는 특정 도메인만 허용
configuration.setAllowedOriginPatterns(List.of("*"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
@@ -0,0 +1,191 @@
package com.phonebill.user.controller;
import com.phonebill.user.dto.*;
import com.phonebill.user.service.AuthService;
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.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* 인증 컨트롤러
* 로그인, 로그아웃, 토큰 갱신 등 인증 관련 API를 제공
*/
@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
@Tag(name = "Authentication", description = "인증 관련 API")
public class AuthController {
private final AuthService authService;
/**
* 사용자 로그인
* @param loginRequest 로그인 요청 정보
* @return 로그인 응답 (JWT 토큰 포함)
*/
@Operation(
summary = "사용자 로그인",
description = "사용자 ID와 비밀번호로 로그인하여 JWT 토큰을 발급받습니다."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "로그인 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청 (입력값 검증 실패)"),
@ApiResponse(responseCode = "401", description = "인증 실패 (잘못된 사용자 ID 또는 비밀번호)"),
@ApiResponse(responseCode = "423", description = "계정 잠금"),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(
@Parameter(description = "로그인 요청 정보", required = true)
@Valid @RequestBody LoginRequest loginRequest
) {
log.info("로그인 요청: userId={}", loginRequest.getUserId());
LoginResponse response = authService.login(loginRequest);
log.info("로그인 성공: userId={}", loginRequest.getUserId());
return ResponseEntity.ok(response);
}
/**
* 토큰 갱신
* @param refreshRequest 토큰 갱신 요청
* @return 새로운 토큰 정보
*/
@Operation(
summary = "토큰 갱신",
description = "Refresh Token을 사용하여 새로운 Access Token과 Refresh Token을 발급받습니다."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "토큰 갱신 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
@ApiResponse(responseCode = "401", description = "유효하지 않은 Refresh Token"),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@PostMapping("/refresh")
public ResponseEntity<RefreshTokenResponse> refreshToken(
@Parameter(description = "토큰 갱신 요청", required = true)
@Valid @RequestBody RefreshTokenRequest refreshRequest
) {
log.info("토큰 갱신 요청");
RefreshTokenResponse response = authService.refreshToken(refreshRequest);
log.info("토큰 갱신 성공");
return ResponseEntity.ok(response);
}
/**
* 로그아웃
* @param userId 사용자 ID
* @param refreshToken Refresh Token
* @return 로그아웃 결과
*/
@Operation(
summary = "사용자 로그아웃",
description = "현재 세션을 종료하고 Refresh Token을 무효화합니다."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "로그아웃 성공"),
@ApiResponse(responseCode = "400", 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
) {
log.info("로그아웃 요청: userId={}", userId);
authService.logout(userId, refreshToken);
log.info("로그아웃 성공: userId={}", userId);
return ResponseEntity.ok("로그아웃이 완료되었습니다.");
}
/**
* 토큰 검증
* @param token 검증할 토큰
* @return 토큰 검증 결과
*/
@Operation(
summary = "토큰 검증",
description = "JWT 토큰의 유효성을 검증하고 토큰 정보를 반환합니다."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "토큰 검증 완료"),
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@GetMapping("/verify")
public ResponseEntity<TokenVerifyResponse> verifyToken(
@Parameter(description = "검증할 JWT 토큰", required = true)
@RequestParam String token
) {
log.info("토큰 검증 요청");
TokenVerifyResponse response = authService.verifyToken(token);
log.info("토큰 검증 완료: valid={}", response.isValid());
return ResponseEntity.ok(response);
}
/**
* 비밀번호 변경
* @param userId 사용자 ID
* @param currentPassword 현재 비밀번호
* @param newPassword 새 비밀번호
* @return 변경 결과
*/
@Operation(
summary = "비밀번호 변경",
description = "현재 비밀번호를 확인하고 새로운 비밀번호로 변경합니다. 변경 후 모든 세션이 무효화됩니다."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "비밀번호 변경 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
@ApiResponse(responseCode = "401", description = "현재 비밀번호가 올바르지 않음"),
@ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음"),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@PostMapping("/change-password")
public ResponseEntity<String> changePassword(
@Parameter(description = "사용자 ID", required = true)
@RequestParam String userId,
@Parameter(description = "현재 비밀번호", required = true)
@RequestParam String currentPassword,
@Parameter(description = "새 비밀번호", required = true)
@RequestParam String newPassword
) {
log.info("비밀번호 변경 요청: userId={}", userId);
authService.changePassword(userId, currentPassword, newPassword);
log.info("비밀번호 변경 성공: userId={}", userId);
return ResponseEntity.ok("비밀번호가 성공적으로 변경되었습니다. 다시 로그인해 주세요.");
}
/**
* 헬스 체크
* @return 서비스 상태
*/
@Operation(
summary = "인증 서비스 헬스 체크",
description = "인증 서비스의 상태를 확인합니다."
)
@ApiResponse(responseCode = "200", description = "서비스 정상")
@GetMapping("/health")
public ResponseEntity<String> healthCheck() {
return ResponseEntity.ok("Auth Service is running");
}
}
@@ -0,0 +1,379 @@
package com.phonebill.user.controller;
import com.phonebill.user.dto.*;
import com.phonebill.user.entity.AuthUserEntity;
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.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 사용자 관리 컨트롤러
* 사용자 정보 조회, 권한 관리 등 사용자 관련 API를 제공
*/
@Slf4j
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
@Tag(name = "User Management", description = "사용자 관리 API")
public class UserController {
private final UserService userService;
/**
* 사용자 정보 조회
* @param userId 사용자 ID
* @return 사용자 정보
*/
@Operation(
summary = "사용자 정보 조회",
description = "사용자 ID로 사용자의 기본 정보를 조회합니다."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "조회 성공"),
@ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음"),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@GetMapping("/{userId}")
public ResponseEntity<UserInfoResponse> getUserInfo(
@Parameter(description = "사용자 ID", required = true)
@PathVariable String userId
) {
log.info("사용자 정보 조회 요청: userId={}", userId);
UserInfoResponse response = userService.getUserInfo(userId);
log.info("사용자 정보 조회 성공: userId={}", userId);
return ResponseEntity.ok(response);
}
/**
* 고객 ID로 사용자 정보 조회
* @param customerId 고객 ID
* @return 사용자 정보
*/
@Operation(
summary = "고객 ID로 사용자 정보 조회",
description = "고객 ID로 해당 고객의 사용자 정보를 조회합니다."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "조회 성공"),
@ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음"),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@GetMapping("/by-customer/{customerId}")
public ResponseEntity<UserInfoResponse> getUserInfoByCustomerId(
@Parameter(description = "고객 ID", required = true)
@PathVariable String customerId
) {
log.info("고객 ID로 사용자 정보 조회 요청: customerId={}", customerId);
UserInfoResponse response = userService.getUserInfoByCustomerId(customerId);
log.info("고객 ID로 사용자 정보 조회 성공: customerId={}", customerId);
return ResponseEntity.ok(response);
}
/**
* 회선번호로 사용자 정보 조회
* @param lineNumber 회선번호
* @return 사용자 정보
*/
@Operation(
summary = "회선번호로 사용자 정보 조회",
description = "회선번호로 해당 회선의 사용자 정보를 조회합니다."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "조회 성공"),
@ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음"),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@GetMapping("/by-line/{lineNumber}")
public ResponseEntity<UserInfoResponse> getUserInfoByLineNumber(
@Parameter(description = "회선번호", required = true)
@PathVariable String lineNumber
) {
log.info("회선번호로 사용자 정보 조회 요청: lineNumber={}", lineNumber);
UserInfoResponse response = userService.getUserInfoByLineNumber(lineNumber);
log.info("회선번호로 사용자 정보 조회 성공: lineNumber={}", lineNumber);
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
* @param permissionCode 권한 코드
* @param grantedBy 권한 부여자
* @return 처리 결과
*/
@Operation(
summary = "권한 부여",
description = "사용자에게 특정 권한을 부여합니다."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "권한 부여 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
@ApiResponse(responseCode = "404", description = "사용자 또는 권한을 찾을 수 없음"),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@PostMapping("/{userId}/permissions/{permissionCode}/grant")
public ResponseEntity<String> grantPermission(
@Parameter(description = "사용자 ID", required = true)
@PathVariable String userId,
@Parameter(description = "권한 코드", required = true)
@PathVariable String permissionCode,
@Parameter(description = "권한 부여자", required = true)
@RequestParam String grantedBy
) {
log.info("권한 부여 요청: userId={}, permissionCode={}, grantedBy={}",
userId, permissionCode, grantedBy);
userService.grantPermission(userId, permissionCode, grantedBy);
log.info("권한 부여 성공: userId={}, permissionCode={}", userId, permissionCode);
return ResponseEntity.ok("권한이 성공적으로 부여되었습니다.");
}
/**
* 권한 철회
* @param userId 사용자 ID
* @param permissionCode 권한 코드
* @return 처리 결과
*/
@Operation(
summary = "권한 철회",
description = "사용자의 특정 권한을 철회합니다."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "권한 철회 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
@ApiResponse(responseCode = "404", description = "사용자 또는 권한을 찾을 수 없음"),
@ApiResponse(responseCode = "500", description = "서버 내부 오류")
})
@DeleteMapping("/{userId}/permissions/{permissionCode}")
public ResponseEntity<String> revokePermission(
@Parameter(description = "사용자 ID", required = true)
@PathVariable String userId,
@Parameter(description = "권한 코드", required = true)
@PathVariable String permissionCode
) {
log.info("권한 철회 요청: userId={}, permissionCode={}", userId, 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");
}
}
@@ -0,0 +1,121 @@
package com.phonebill.user.domain;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
import java.time.LocalDateTime;
import java.util.List;
/**
* 사용자 도메인 모델
* 비즈니스 로직을 포함하는 사용자 정보
*/
@Getter
@Builder
@ToString(exclude = "permissions") // 순환 참조 방지
public class User {
private final String userId;
private final String customerId;
private final String lineNumber;
private final UserStatus status;
private final Integer failedLoginCount;
private final LocalDateTime lastFailedLoginAt;
private final LocalDateTime accountLockedUntil;
private final LocalDateTime lastLoginAt;
private final LocalDateTime lastPasswordChangedAt;
private final List<String> permissions;
/**
* 사용자 상태 열거형
*/
public enum UserStatus {
ACTIVE, // 활성
LOCKED, // 잠금
SUSPENDED, // 정지
INACTIVE // 비활성
}
/**
* 계정 활성 상태 확인
*/
public boolean isAccountActive() {
return status == UserStatus.ACTIVE && !isAccountLocked();
}
/**
* 계정 잠금 상태 확인
*/
public boolean isAccountLocked() {
if (status != UserStatus.LOCKED) {
return false;
}
// 잠금 해제 시간이 지났으면 잠금 해제로 판단
return accountLockedUntil == null || LocalDateTime.now().isBefore(accountLockedUntil);
}
/**
* 로그인 실패 임계치 확인
* @param maxFailedAttempts 최대 허용 실패 횟수
*/
public boolean isLoginFailureThresholdExceeded(int maxFailedAttempts) {
return failedLoginCount != null && failedLoginCount >= maxFailedAttempts;
}
/**
* 특정 권한 보유 여부 확인
*/
public boolean hasPermission(String permissionCode) {
return permissions != null && permissions.contains(permissionCode);
}
/**
* 서비스별 권한 보유 여부 확인
* @param serviceType 서비스 타입 (BILL_INQUIRY, PRODUCT_CHANGE)
*/
public boolean hasServicePermission(String serviceType) {
return hasPermission(serviceType);
}
/**
* 비밀번호 변경이 필요한지 확인
* @param passwordChangeIntervalDays 비밀번호 변경 주기 (일)
*/
public boolean isPasswordChangeRequired(int passwordChangeIntervalDays) {
if (lastPasswordChangedAt == null) {
return true; // 비밀번호 변경 이력이 없으면 변경 필요
}
LocalDateTime changeRequiredDate = lastPasswordChangedAt.plusDays(passwordChangeIntervalDays);
return LocalDateTime.now().isAfter(changeRequiredDate);
}
/**
* 마지막 로그인으로부터 경과된 일수 계산
*/
public long getDaysSinceLastLogin() {
if (lastLoginAt == null) {
return Long.MAX_VALUE; // 로그인 이력이 없으면 최대값 반환
}
return java.time.temporal.ChronoUnit.DAYS.between(lastLoginAt, LocalDateTime.now());
}
/**
* 사용자 기본 정보 조회용 빌더 (보안 정보 제외)
*/
public static User createBasicInfo(String userId, String customerId, String lineNumber,
UserStatus status, LocalDateTime lastLoginAt,
List<String> permissions) {
return User.builder()
.userId(userId)
.customerId(customerId)
.lineNumber(lineNumber)
.status(status)
.lastLoginAt(lastLoginAt)
.permissions(permissions)
.build();
}
}
@@ -0,0 +1,111 @@
package com.phonebill.user.domain;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
import java.time.LocalDateTime;
/**
* 사용자 세션 도메인 모델
*/
@Getter
@Builder
@ToString(exclude = {"sessionToken", "refreshToken"}) // 보안 정보 제외
public class UserSession {
private final String sessionId;
private final String userId;
private final String sessionToken;
private final String refreshToken;
private final String clientIp;
private final String userAgent;
private final boolean autoLoginEnabled;
private final LocalDateTime expiresAt;
private final LocalDateTime lastAccessedAt;
private final LocalDateTime createdAt;
/**
* 세션 만료 여부 확인
*/
public boolean isExpired() {
return LocalDateTime.now().isAfter(expiresAt);
}
/**
* 세션 활성 상태 확인
*/
public boolean isActive() {
return !isExpired();
}
/**
* 세션 만료까지 남은 시간 계산 (초)
*/
public long getSecondsUntilExpiry() {
if (isExpired()) {
return 0;
}
return java.time.temporal.ChronoUnit.SECONDS.between(LocalDateTime.now(), expiresAt);
}
/**
* 마지막 접근으로부터 경과 시간 계산 (초)
*/
public long getSecondsSinceLastAccess() {
if (lastAccessedAt == null) {
return java.time.temporal.ChronoUnit.SECONDS.between(createdAt, LocalDateTime.now());
}
return java.time.temporal.ChronoUnit.SECONDS.between(lastAccessedAt, LocalDateTime.now());
}
/**
* 세션 유휴 상태 확인
* @param idleTimeoutSeconds 유휴 시간 임계값 (초)
*/
public boolean isIdle(long idleTimeoutSeconds) {
return getSecondsSinceLastAccess() > idleTimeoutSeconds;
}
/**
* 클라이언트 정보 일치 여부 확인 (보안 검증용)
*/
public boolean matchesClientInfo(String clientIp, String userAgent) {
return this.clientIp.equals(clientIp) && this.userAgent.equals(userAgent);
}
/**
* 세션 정보 요약 조회용 (보안 정보 제외)
*/
public SessionSummary toSummary() {
return SessionSummary.builder()
.sessionId(sessionId)
.userId(userId)
.clientIp(clientIp)
.autoLoginEnabled(autoLoginEnabled)
.expiresAt(expiresAt)
.lastAccessedAt(lastAccessedAt)
.createdAt(createdAt)
.active(isActive())
.build();
}
/**
* 세션 요약 정보 (보안 정보 제외)
*/
@Getter
@Builder
@ToString
public static class SessionSummary {
private final String sessionId;
private final String userId;
private final String clientIp;
private final boolean autoLoginEnabled;
private final LocalDateTime expiresAt;
private final LocalDateTime lastAccessedAt;
private final LocalDateTime createdAt;
private final boolean active;
}
}
@@ -0,0 +1,40 @@
package com.phonebill.user.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 로그인 요청 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LoginRequest {
@NotBlank(message = "사용자 ID는 필수입니다")
@Size(min = 3, max = 20, message = "사용자 ID는 3-20자 사이여야 합니다")
@Pattern(regexp = "^[a-zA-Z0-9_-]+$", message = "사용자 ID는 영문, 숫자, '_', '-'만 사용 가능합니다")
private String userId;
@NotBlank(message = "비밀번호는 필수입니다")
@Size(min = 8, max = 50, message = "비밀번호는 8-50자 사이여야 합니다")
private String password;
@Builder.Default
private Boolean autoLogin = false;
// 보안을 위해 toString에서 비밀번호 제외
@Override
public String toString() {
return "LoginRequest{" +
"userId='" + userId + '\'' +
", autoLogin=" + autoLogin +
'}';
}
}
@@ -0,0 +1,39 @@
package com.phonebill.user.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 로그인 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LoginResponse {
private String accessToken;
private String refreshToken;
private String tokenType;
private Integer expiresIn; // 초 단위
private String userId;
private String customerId;
private String lineNumber;
private UserInfo user;
/**
* 사용자 정보 내부 클래스
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class UserInfo {
private String userId;
private String userName;
private String phoneNumber;
private java.util.List<String> permissions;
}
}
@@ -0,0 +1,15 @@
package com.phonebill.user.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 로그아웃 요청 DTO
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class LogoutRequest {
private String refreshToken;
}
@@ -0,0 +1,29 @@
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;
}
@@ -0,0 +1,36 @@
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;
}
}
@@ -0,0 +1,17 @@
package com.phonebill.user.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 권한 확인 요청 DTO
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class PermissionRequest {
private String userId;
private String resource;
private String action;
}
@@ -0,0 +1,18 @@
package com.phonebill.user.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 권한 확인 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PermissionResponse {
private boolean hasPermission;
private String message;
}
@@ -0,0 +1,34 @@
package com.phonebill.user.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 사용자 권한 목록 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PermissionsResponse {
private String userId;
private List<Permission> permissions;
/**
* 권한 정보
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Permission {
private String permission;
private String description;
private Boolean granted;
}
}
@@ -0,0 +1,28 @@
package com.phonebill.user.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 토큰 갱신 요청 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RefreshTokenRequest {
@NotBlank(message = "리프레시 토큰은 필수입니다")
private String refreshToken;
// 보안을 위해 toString에서 토큰 일부만 표시
@Override
public String toString() {
return "RefreshTokenRequest{" +
"refreshToken='" + (refreshToken != null ? refreshToken.substring(0, Math.min(refreshToken.length(), 10)) + "..." : "null") +
'}';
}
}
@@ -0,0 +1,21 @@
package com.phonebill.user.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 토큰 갱신 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RefreshTokenResponse {
private String accessToken;
private String refreshToken;
private String tokenType;
private Integer expiresIn; // 초 단위
}
@@ -0,0 +1,15 @@
package com.phonebill.user.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 토큰 갱신 요청 DTO
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class TokenRefreshRequest {
private String refreshToken;
}
@@ -0,0 +1,20 @@
package com.phonebill.user.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 토큰 갱신 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TokenRefreshResponse {
private String accessToken;
private String refreshToken;
private String tokenType;
private Long expiresIn;
}
@@ -0,0 +1,59 @@
package com.phonebill.user.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 토큰 검증 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TokenVerifyResponse {
private boolean valid;
private String userId;
private String customerId;
private String lineNumber;
private LocalDateTime expiresAt;
private String message;
/**
* 유효한 토큰에 대한 응답 생성
*/
public static TokenVerifyResponse valid(String userId, String customerId, String lineNumber, LocalDateTime expiresAt) {
return TokenVerifyResponse.builder()
.valid(true)
.userId(userId)
.customerId(customerId)
.lineNumber(lineNumber)
.expiresAt(expiresAt)
.message("유효한 토큰입니다.")
.build();
}
/**
* 유효하지 않은 토큰에 대한 응답 생성
*/
public static TokenVerifyResponse invalid() {
return TokenVerifyResponse.builder()
.valid(false)
.message("유효하지 않은 토큰입니다.")
.build();
}
/**
* 만료된 토큰에 대한 응답 생성
*/
public static TokenVerifyResponse expired() {
return TokenVerifyResponse.builder()
.valid(false)
.message("만료된 토큰입니다.")
.build();
}
}
@@ -0,0 +1,37 @@
package com.phonebill.user.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 사용자 정보 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserInfoResponse {
private String userId;
private String customerId;
private String lineNumber;
private String userName;
private String phoneNumber;
private String email;
private String status;
private String accountStatus;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime lastLoginAt;
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime lastPasswordChangedAt;
private List<String> permissions;
}
@@ -0,0 +1,52 @@
package com.phonebill.user.entity;
import jakarta.persistence.*;
import lombok.*;
/**
* 권한 정의 엔티티
* 시스템 내 권한 정보를 관리
*/
@Entity
@Table(name = "auth_permissions")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class AuthPermissionEntity extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "permission_id")
private Long permissionId;
@Column(name = "service_code", nullable = false, length = 30)
private String serviceCode;
@Column(name = "permission_code", nullable = false, length = 50)
private String permissionCode;
@Column(name = "permission_name", nullable = false, length = 100)
private String permissionName;
@Column(name = "permission_description", columnDefinition = "TEXT")
private String permissionDescription;
@Column(name = "is_active")
@Builder.Default
private Boolean isActive = true;
/**
* 권한 활성화
*/
public void activate() {
this.isActive = true;
}
/**
* 권한 비활성화
*/
public void deactivate() {
this.isActive = false;
}
}
@@ -0,0 +1,141 @@
package com.phonebill.user.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
/**
* 사용자 계정 엔티티
* 사용자의 기본 정보 및 인증 정보를 관리
*/
@Entity
@Table(name = "auth_users")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class AuthUserEntity extends BaseTimeEntity {
@Id
@Column(name = "user_id", length = 50)
private String userId;
@Column(name = "password_hash", nullable = false, length = 255)
private String passwordHash;
@Column(name = "password_salt", nullable = false, length = 100)
private String passwordSalt;
@Column(name = "customer_id", nullable = false, length = 50)
private String customerId;
@Column(name = "line_number", length = 20)
private String lineNumber;
@Enumerated(EnumType.STRING)
@Column(name = "account_status", length = 20)
@Builder.Default
private AccountStatus accountStatus = AccountStatus.ACTIVE;
@Column(name = "failed_login_count")
@Builder.Default
private Integer failedLoginCount = 0;
@Column(name = "last_failed_login_at")
private LocalDateTime lastFailedLoginAt;
@Column(name = "account_locked_until")
private LocalDateTime accountLockedUntil;
@Column(name = "last_login_at")
private LocalDateTime lastLoginAt;
@Column(name = "last_password_changed_at")
private LocalDateTime lastPasswordChangedAt;
/**
* 계정 상태 열거형
*/
public enum AccountStatus {
ACTIVE, // 활성
LOCKED, // 잠금
SUSPENDED, // 정지
INACTIVE // 비활성
}
/**
* 로그인 실패 카운트 증가
*/
public void incrementFailedLoginCount() {
this.failedLoginCount = (this.failedLoginCount == null ? 0 : this.failedLoginCount) + 1;
this.lastFailedLoginAt = LocalDateTime.now();
}
/**
* 로그인 실패 카운트 초기화
*/
public void resetFailedLoginCount() {
this.failedLoginCount = 0;
this.lastFailedLoginAt = null;
}
/**
* 계정 잠금
* @param lockoutDuration 잠금 지속시간 (밀리초)
*/
public void lockAccount(long lockoutDuration) {
this.accountStatus = AccountStatus.LOCKED;
this.accountLockedUntil = LocalDateTime.now().plusNanos(lockoutDuration * 1_000_000);
}
/**
* 계정 잠금 해제
*/
public void unlockAccount() {
this.accountStatus = AccountStatus.ACTIVE;
this.accountLockedUntil = null;
this.resetFailedLoginCount();
}
/**
* 계정 잠금 상태 확인
*/
public boolean isAccountLocked() {
if (this.accountStatus != AccountStatus.LOCKED) {
return false;
}
// 잠금 해제 시간이 지났으면 자동 해제
if (this.accountLockedUntil != null && LocalDateTime.now().isAfter(this.accountLockedUntil)) {
this.unlockAccount();
return false;
}
return true;
}
/**
* 로그인 성공 처리
*/
public void updateLastLogin() {
this.lastLoginAt = LocalDateTime.now();
this.resetFailedLoginCount();
}
/**
* 비밀번호 변경
*/
public void updatePassword(String passwordHash, String passwordSalt) {
this.passwordHash = passwordHash;
this.passwordSalt = passwordSalt;
this.lastPasswordChangedAt = LocalDateTime.now();
}
/**
* 계정 활성 상태 확인
*/
public boolean isAccountActive() {
return this.accountStatus == AccountStatus.ACTIVE && !isAccountLocked();
}
}
@@ -0,0 +1,57 @@
package com.phonebill.user.entity;
import jakarta.persistence.*;
import lombok.*;
/**
* 사용자 권한 엔티티
* 사용자와 권한의 매핑 관계를 관리
*/
@Entity
@Table(name = "auth_user_permissions")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class AuthUserPermissionEntity extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_permission_id")
private Long userPermissionId;
@Column(name = "user_id", nullable = false, length = 50)
private String userId;
@Column(name = "permission_id", nullable = false)
private Long permissionId;
@Column(name = "granted")
@Builder.Default
private Boolean granted = true;
@Column(name = "granted_by", length = 50)
private String grantedBy;
/**
* 권한 부여
*/
public void grantPermission(String grantedBy) {
this.granted = true;
this.grantedBy = grantedBy;
}
/**
* 권한 철회
*/
public void revokePermission() {
this.granted = false;
}
/**
* 권한 보유 여부 확인
*/
public boolean hasPermission() {
return Boolean.TRUE.equals(this.granted);
}
}
@@ -0,0 +1,105 @@
package com.phonebill.user.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
/**
* 사용자 세션 엔티티
* 사용자의 로그인 세션 정보를 관리
*/
@Entity
@Table(name = "auth_user_sessions")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class AuthUserSessionEntity extends BaseTimeEntity {
@Id
@Column(name = "session_id", length = 100)
private String sessionId;
@Column(name = "user_id", nullable = false, length = 50)
private String userId;
@Column(name = "session_token", nullable = false, length = 500)
private String sessionToken;
@Column(name = "refresh_token", length = 500)
private String refreshToken;
@Column(name = "client_ip", length = 45)
private String clientIp;
@Column(name = "user_agent", columnDefinition = "TEXT")
private String userAgent;
@Column(name = "auto_login_enabled")
@Builder.Default
private Boolean autoLoginEnabled = false;
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;
@Column(name = "last_accessed_at")
@Builder.Default
private LocalDateTime lastAccessedAt = LocalDateTime.now();
@Column(name = "is_active")
@Builder.Default
private Boolean isActive = true;
/**
* 세션 만료 여부 확인
*/
public boolean isExpired() {
return LocalDateTime.now().isAfter(this.expiresAt);
}
/**
* 마지막 접근 시간 업데이트
*/
public void updateLastAccessedAt() {
this.lastAccessedAt = LocalDateTime.now();
}
/**
* 세션 토큰 갱신
*/
public void updateSessionToken(String newSessionToken, LocalDateTime newExpiresAt) {
this.sessionToken = newSessionToken;
this.expiresAt = newExpiresAt;
this.updateLastAccessedAt();
}
/**
* 리프레시 토큰 갱신
*/
public void updateRefreshToken(String newRefreshToken) {
this.refreshToken = newRefreshToken;
this.updateLastAccessedAt();
}
/**
* 세션 활성 상태 확인
*/
public boolean isActive() {
return this.isActive && !isExpired();
}
/**
* 세션 비활성화
*/
public void deactivate() {
this.isActive = false;
}
/**
* 세션 활성화
*/
public void activate() {
this.isActive = true;
}
}
@@ -0,0 +1,29 @@
package com.phonebill.user.entity;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 기본 시간 엔티티
* 생성일시와 수정일시를 자동으로 관리하는 기본 엔티티
*/
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}
@@ -0,0 +1,36 @@
package com.phonebill.user.exception;
import java.time.LocalDateTime;
/**
* 계정이 잠겨있을 때 발생하는 예외
*/
public class AccountLockedException extends RuntimeException {
private final LocalDateTime lockedUntil;
public AccountLockedException(String message, LocalDateTime lockedUntil) {
super(message);
this.lockedUntil = lockedUntil;
}
public AccountLockedException(String message, Throwable cause, LocalDateTime lockedUntil) {
super(message, cause);
this.lockedUntil = lockedUntil;
}
public LocalDateTime getLockedUntil() {
return lockedUntil;
}
public static AccountLockedException create(LocalDateTime lockedUntil) {
return new AccountLockedException("계정이 잠금 상태입니다. 잠금 해제 시간: " + lockedUntil, lockedUntil);
}
public static AccountLockedException create(String userId, LocalDateTime lockedUntil) {
return new AccountLockedException(
String.format("계정이 잠금 상태입니다. userId: %s, 잠금 해제 시간: %s", userId, lockedUntil),
lockedUntil
);
}
}
@@ -0,0 +1,24 @@
package com.phonebill.user.exception;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 에러 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ErrorResponse {
private LocalDateTime timestamp;
private int status;
private String error;
private String message;
private String path;
}
@@ -0,0 +1,23 @@
package com.phonebill.user.exception;
/**
* 잘못된 인증 정보로 인한 예외
*/
public class InvalidCredentialsException extends RuntimeException {
public InvalidCredentialsException(String message) {
super(message);
}
public InvalidCredentialsException(String message, Throwable cause) {
super(message, cause);
}
public static InvalidCredentialsException create() {
return new InvalidCredentialsException("아이디 또는 비밀번호가 올바르지 않습니다.");
}
public static InvalidCredentialsException invalidPassword() {
return new InvalidCredentialsException("비밀번호가 올바르지 않습니다.");
}
}
@@ -0,0 +1,39 @@
package com.phonebill.user.exception;
/**
* 유효하지 않은 토큰으로 인한 예외
*/
public class InvalidTokenException extends RuntimeException {
public InvalidTokenException(String message) {
super(message);
}
public InvalidTokenException(String message, Throwable cause) {
super(message, cause);
}
public static InvalidTokenException expired() {
return new InvalidTokenException("토큰이 만료되었습니다.");
}
public static InvalidTokenException invalid() {
return new InvalidTokenException("유효하지 않은 토큰입니다.");
}
public static InvalidTokenException malformed() {
return new InvalidTokenException("잘못된 형식의 토큰입니다.");
}
public static InvalidTokenException signatureInvalid() {
return new InvalidTokenException("토큰 서명이 유효하지 않습니다.");
}
public static InvalidTokenException notAccessToken() {
return new InvalidTokenException("Access Token이 아닙니다.");
}
public static InvalidTokenException notRefreshToken() {
return new InvalidTokenException("Refresh Token이 아닙니다.");
}
}
@@ -0,0 +1,27 @@
package com.phonebill.user.exception;
/**
* 사용자를 찾을 수 없을 때 발생하는 예외
*/
public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(String message) {
super(message);
}
public UserNotFoundException(String message, Throwable cause) {
super(message, cause);
}
public static UserNotFoundException byUserId(String userId) {
return new UserNotFoundException("사용자를 찾을 수 없습니다. userId: " + userId);
}
public static UserNotFoundException byCustomerId(String customerId) {
return new UserNotFoundException("사용자를 찾을 수 없습니다. customerId: " + customerId);
}
public static UserNotFoundException byLineNumber(String lineNumber) {
return new UserNotFoundException("사용자를 찾을 수 없습니다. lineNumber: " + lineNumber);
}
}
@@ -0,0 +1,162 @@
package com.phonebill.user.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
/**
* User Service 전용 예외 처리 핸들러
*/
@Slf4j
@RestControllerAdvice
public class UserServiceExceptionHandler {
/**
* 사용자를 찾을 수 없는 경우
*/
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFoundException(UserNotFoundException e) {
log.warn("UserNotFoundException: {}", e.getMessage());
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.NOT_FOUND.value())
.error(HttpStatus.NOT_FOUND.getReasonPhrase())
.message(e.getMessage())
.path(getRequestPath())
.build();
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
}
/**
* 잘못된 인증 정보
*/
@ExceptionHandler(InvalidCredentialsException.class)
public ResponseEntity<ErrorResponse> handleInvalidCredentialsException(InvalidCredentialsException e) {
log.warn("InvalidCredentialsException: {}", e.getMessage());
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.UNAUTHORIZED.value())
.error(HttpStatus.UNAUTHORIZED.getReasonPhrase())
.message(e.getMessage())
.path(getRequestPath())
.build();
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse);
}
/**
* 계정 잠금
*/
@ExceptionHandler(AccountLockedException.class)
public ResponseEntity<Map<String, Object>> handleAccountLockedException(AccountLockedException e) {
log.warn("AccountLockedException: {}", e.getMessage());
Map<String, Object> response = new HashMap<>();
response.put("timestamp", LocalDateTime.now());
response.put("status", HttpStatus.LOCKED.value());
response.put("error", HttpStatus.LOCKED.getReasonPhrase());
response.put("message", e.getMessage());
response.put("lockedUntil", e.getLockedUntil());
response.put("path", getRequestPath());
return ResponseEntity.status(HttpStatus.LOCKED).body(response);
}
/**
* 유효하지 않은 토큰
*/
@ExceptionHandler(InvalidTokenException.class)
public ResponseEntity<ErrorResponse> handleInvalidTokenException(InvalidTokenException e) {
log.warn("InvalidTokenException: {}", e.getMessage());
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.UNAUTHORIZED.value())
.error(HttpStatus.UNAUTHORIZED.getReasonPhrase())
.message(e.getMessage())
.path(getRequestPath())
.build();
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResponse);
}
/**
* 입력값 검증 실패
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidationException(MethodArgumentNotValidException e) {
log.warn("ValidationException: {}", e.getMessage());
Map<String, String> fieldErrors = new HashMap<>();
e.getBindingResult().getAllErrors().forEach(error -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
fieldErrors.put(fieldName, errorMessage);
});
Map<String, Object> response = new HashMap<>();
response.put("timestamp", LocalDateTime.now());
response.put("status", HttpStatus.BAD_REQUEST.value());
response.put("error", HttpStatus.BAD_REQUEST.getReasonPhrase());
response.put("message", "입력값 검증에 실패했습니다.");
response.put("fieldErrors", fieldErrors);
response.put("path", getRequestPath());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
/**
* 일반적인 런타임 예외
*/
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponse> handleRuntimeException(RuntimeException e) {
log.error("RuntimeException: {}", e.getMessage(), e);
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
.error(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase())
.message("서버 내부 오류가 발생했습니다.")
.path(getRequestPath())
.build();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
}
/**
* 모든 예외 처리
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception e) {
log.error("Exception: {}", e.getMessage(), e);
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
.error(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase())
.message("예기치 않은 오류가 발생했습니다.")
.path(getRequestPath())
.build();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
}
/**
* 현재 요청 경로 반환 (단순화된 버전)
*/
private String getRequestPath() {
// 실제 구현에서는 HttpServletRequest를 주입받아 사용
return "/api/auth";
}
}
@@ -0,0 +1,55 @@
package com.phonebill.user.repository;
import com.phonebill.user.entity.AuthPermissionEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 권한 정의 Repository
*/
@Repository
public interface AuthPermissionRepository extends JpaRepository<AuthPermissionEntity, Long> {
/**
* 서비스 코드로 권한 목록 조회
*/
List<AuthPermissionEntity> findByServiceCodeAndIsActiveTrue(String serviceCode);
/**
* 권한 코드로 권한 조회
*/
Optional<AuthPermissionEntity> findByPermissionCodeAndIsActiveTrue(String permissionCode);
/**
* 서비스 코드와 권한 코드로 권한 조회
*/
Optional<AuthPermissionEntity> findByServiceCodeAndPermissionCodeAndIsActiveTrue(
String serviceCode, String permissionCode);
/**
* 모든 활성 권한 조회
*/
List<AuthPermissionEntity> findByIsActiveTrue();
/**
* 서비스 코드 존재 여부 확인
*/
boolean existsByServiceCodeAndIsActiveTrue(String serviceCode);
/**
* 권한 코드 존재 여부 확인
*/
boolean existsByPermissionCodeAndIsActiveTrue(String permissionCode);
/**
* 서비스별 활성 권한 수 조회
*/
@Query("SELECT COUNT(p) FROM AuthPermissionEntity p " +
"WHERE p.serviceCode = :serviceCode AND p.isActive = true")
long countActivePermissionsByServiceCode(@Param("serviceCode") String serviceCode);
}
@@ -0,0 +1,106 @@
package com.phonebill.user.repository;
import com.phonebill.user.entity.AuthUserPermissionEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 사용자 권한 Repository
*/
@Repository
public interface AuthUserPermissionRepository extends JpaRepository<AuthUserPermissionEntity, Long> {
/**
* 사용자의 모든 권한 조회
*/
List<AuthUserPermissionEntity> findByUserId(String userId);
/**
* 사용자의 부여된 권한만 조회
*/
List<AuthUserPermissionEntity> findByUserIdAndGrantedTrue(String userId);
/**
* 사용자의 특정 권한 조회
*/
Optional<AuthUserPermissionEntity> findByUserIdAndPermissionId(String userId, Long permissionId);
/**
* 사용자의 특정 권한 보유 여부 확인
*/
@Query("SELECT CASE WHEN COUNT(up) > 0 THEN true ELSE false END " +
"FROM AuthUserPermissionEntity up " +
"WHERE up.userId = :userId AND up.permissionId = :permissionId AND up.granted = true")
boolean hasPermission(@Param("userId") String userId, @Param("permissionId") Long permissionId);
/**
* 서비스별 사용자 권한 조회
*/
@Query("SELECT up FROM AuthUserPermissionEntity up " +
"JOIN AuthPermissionEntity p ON up.permissionId = p.permissionId " +
"WHERE up.userId = :userId AND p.serviceCode = :serviceCode AND up.granted = true")
List<AuthUserPermissionEntity> findUserPermissionsByService(@Param("userId") String userId,
@Param("serviceCode") String serviceCode);
/**
* 권한 코드로 사용자 권한 조회
*/
@Query("SELECT up FROM AuthUserPermissionEntity up " +
"JOIN AuthPermissionEntity p ON up.permissionId = p.permissionId " +
"WHERE up.userId = :userId AND p.permissionCode = :permissionCode AND up.granted = true")
Optional<AuthUserPermissionEntity> findByUserIdAndPermissionCode(@Param("userId") String userId,
@Param("permissionCode") String permissionCode);
/**
* 사용자가 보유한 권한 코드 목록 조회
*/
@Query("SELECT p.permissionCode FROM AuthUserPermissionEntity up " +
"JOIN AuthPermissionEntity p ON up.permissionId = p.permissionId " +
"WHERE up.userId = :userId AND up.granted = true AND p.isActive = true")
List<String> findPermissionCodesByUserId(@Param("userId") String userId);
/**
* 권한 부여
*/
@Modifying
@Query("UPDATE AuthUserPermissionEntity up SET up.granted = true, up.grantedBy = :grantedBy " +
"WHERE up.userId = :userId AND up.permissionId = :permissionId")
int grantPermission(@Param("userId") String userId,
@Param("permissionId") Long permissionId,
@Param("grantedBy") String grantedBy);
/**
* 권한 철회
*/
@Modifying
@Query("UPDATE AuthUserPermissionEntity up SET up.granted = false " +
"WHERE up.userId = :userId AND up.permissionId = :permissionId")
int revokePermission(@Param("userId") String userId, @Param("permissionId") Long permissionId);
/**
* 사용자의 모든 권한 철회
*/
@Modifying
@Query("UPDATE AuthUserPermissionEntity up SET up.granted = false WHERE up.userId = :userId")
int revokeAllPermissions(@Param("userId") String userId);
/**
* 사용자의 모든 권한 삭제
*/
@Modifying
@Query("DELETE FROM AuthUserPermissionEntity up WHERE up.userId = :userId")
int deleteAllByUserId(@Param("userId") String userId);
/**
* 특정 권한을 가진 사용자 수 조회
*/
@Query("SELECT COUNT(DISTINCT up.userId) FROM AuthUserPermissionEntity up " +
"WHERE up.permissionId = :permissionId AND up.granted = true")
long countUsersWithPermission(@Param("permissionId") Long permissionId);
}
@@ -0,0 +1,109 @@
package com.phonebill.user.repository;
import com.phonebill.user.entity.AuthUserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.Optional;
/**
* 사용자 계정 Repository
*/
@Repository
public interface AuthUserRepository extends JpaRepository<AuthUserEntity, String> {
/**
* 고객 ID로 사용자 조회
*/
Optional<AuthUserEntity> findByCustomerId(String customerId);
/**
* 회선번호로 사용자 조회
*/
Optional<AuthUserEntity> findByLineNumber(String lineNumber);
/**
* 활성 상태인 사용자만 조회
*/
Optional<AuthUserEntity> findByUserIdAndAccountStatus(String userId, AuthUserEntity.AccountStatus status);
/**
* 사용자 ID 존재 여부 확인
*/
boolean existsByUserId(String userId);
/**
* 고객 ID 존재 여부 확인
*/
boolean existsByCustomerId(String customerId);
/**
* 로그인 실패 카운트 증가
*/
@Modifying
@Query("UPDATE AuthUserEntity u SET u.failedLoginCount = u.failedLoginCount + 1, " +
"u.lastFailedLoginAt = :failedTime WHERE u.userId = :userId")
int incrementFailedLoginCount(@Param("userId") String userId,
@Param("failedTime") LocalDateTime failedTime);
/**
* 로그인 실패 카운트 초기화
*/
@Modifying
@Query("UPDATE AuthUserEntity u SET u.failedLoginCount = 0, " +
"u.lastFailedLoginAt = null WHERE u.userId = :userId")
int resetFailedLoginCount(@Param("userId") String userId);
/**
* 계정 잠금 설정
*/
@Modifying
@Query("UPDATE AuthUserEntity u SET u.accountStatus = 'LOCKED', " +
"u.accountLockedUntil = :lockedUntil WHERE u.userId = :userId")
int lockAccount(@Param("userId") String userId,
@Param("lockedUntil") LocalDateTime lockedUntil);
/**
* 계정 잠금 해제
*/
@Modifying
@Query("UPDATE AuthUserEntity u SET u.accountStatus = 'ACTIVE', " +
"u.accountLockedUntil = null, u.failedLoginCount = 0, " +
"u.lastFailedLoginAt = null WHERE u.userId = :userId")
int unlockAccount(@Param("userId") String userId);
/**
* 마지막 로그인 시간 업데이트
*/
@Modifying
@Query("UPDATE AuthUserEntity u SET u.lastLoginAt = :loginTime, " +
"u.failedLoginCount = 0, u.lastFailedLoginAt = null WHERE u.userId = :userId")
int updateLastLoginTime(@Param("userId") String userId,
@Param("loginTime") LocalDateTime loginTime);
/**
* 비밀번호 업데이트
*/
@Modifying
@Query("UPDATE AuthUserEntity u SET u.passwordHash = :passwordHash, " +
"u.passwordSalt = :passwordSalt, u.lastPasswordChangedAt = :changedTime " +
"WHERE u.userId = :userId")
int updatePassword(@Param("userId") String userId,
@Param("passwordHash") String passwordHash,
@Param("passwordSalt") String passwordSalt,
@Param("changedTime") LocalDateTime changedTime);
/**
* 잠금 해제 시간이 지난 계정들의 잠금 자동 해제
*/
@Modifying
@Query("UPDATE AuthUserEntity u SET u.accountStatus = 'ACTIVE', " +
"u.accountLockedUntil = null, u.failedLoginCount = 0, " +
"u.lastFailedLoginAt = null " +
"WHERE u.accountStatus = 'LOCKED' AND u.accountLockedUntil < :currentTime")
int unlockExpiredAccounts(@Param("currentTime") LocalDateTime currentTime);
}
@@ -0,0 +1,120 @@
package com.phonebill.user.repository;
import com.phonebill.user.entity.AuthUserSessionEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* 사용자 세션 Repository
*/
@Repository
public interface AuthUserSessionRepository extends JpaRepository<AuthUserSessionEntity, String> {
/**
* 사용자 ID로 활성 세션 조회
*/
List<AuthUserSessionEntity> findByUserIdAndExpiresAtAfter(String userId, LocalDateTime currentTime);
/**
* 세션 토큰으로 세션 조회
*/
Optional<AuthUserSessionEntity> findBySessionToken(String sessionToken);
/**
* 리프레시 토큰으로 세션 조회
*/
Optional<AuthUserSessionEntity> findByRefreshToken(String refreshToken);
/**
* 특정 사용자의 모든 세션 조회
*/
List<AuthUserSessionEntity> findByUserId(String userId);
/**
* 만료된 세션 조회
*/
List<AuthUserSessionEntity> findByExpiresAtBefore(LocalDateTime expirationTime);
/**
* 특정 사용자의 활성 세션 수 조회
*/
@Query("SELECT COUNT(s) FROM AuthUserSessionEntity s WHERE s.userId = :userId AND s.expiresAt > :currentTime")
long countActiveSessionsByUserId(@Param("userId") String userId, @Param("currentTime") LocalDateTime currentTime);
/**
* 마지막 접근 시간 업데이트
*/
@Modifying
@Query("UPDATE AuthUserSessionEntity s SET s.lastAccessedAt = :accessTime " +
"WHERE s.sessionId = :sessionId")
int updateLastAccessedTime(@Param("sessionId") String sessionId,
@Param("accessTime") LocalDateTime accessTime);
/**
* 세션 토큰 업데이트
*/
@Modifying
@Query("UPDATE AuthUserSessionEntity s SET s.sessionToken = :sessionToken, " +
"s.expiresAt = :expiresAt, s.lastAccessedAt = :accessTime " +
"WHERE s.sessionId = :sessionId")
int updateSessionToken(@Param("sessionId") String sessionId,
@Param("sessionToken") String sessionToken,
@Param("expiresAt") LocalDateTime expiresAt,
@Param("accessTime") LocalDateTime accessTime);
/**
* 리프레시 토큰 업데이트
*/
@Modifying
@Query("UPDATE AuthUserSessionEntity s SET s.refreshToken = :refreshToken, " +
"s.lastAccessedAt = :accessTime WHERE s.sessionId = :sessionId")
int updateRefreshToken(@Param("sessionId") String sessionId,
@Param("refreshToken") String refreshToken,
@Param("accessTime") LocalDateTime accessTime);
/**
* 특정 사용자의 모든 세션 삭제
*/
@Modifying
@Query("DELETE FROM AuthUserSessionEntity s WHERE s.userId = :userId")
int deleteAllByUserId(@Param("userId") String userId);
/**
* 만료된 세션 삭제
*/
@Modifying
@Query("DELETE FROM AuthUserSessionEntity s WHERE s.expiresAt < :expirationTime")
int deleteExpiredSessions(@Param("expirationTime") LocalDateTime expirationTime);
/**
* 특정 세션 ID로 세션 삭제
*/
@Modifying
@Query("DELETE FROM AuthUserSessionEntity s WHERE s.sessionId = :sessionId")
int deleteBySessionId(@Param("sessionId") String sessionId);
/**
* IP와 User-Agent로 세션 조회 (보안 검증용)
*/
Optional<AuthUserSessionEntity> findBySessionIdAndClientIpAndUserAgent(
String sessionId, String clientIp, String userAgent);
/**
* 사용자 ID와 리프레시 토큰으로 활성 세션 조회
*/
Optional<AuthUserSessionEntity> findByUserIdAndRefreshTokenAndIsActiveTrue(String userId, String refreshToken);
/**
* 사용자의 모든 세션 비활성화
*/
@Modifying
@Query("UPDATE AuthUserSessionEntity s SET s.isActive = false WHERE s.userId = :userId")
int deactivateAllUserSessions(@Param("userId") String userId);
}
@@ -0,0 +1,292 @@
package com.phonebill.user.service;
import com.phonebill.user.config.JwtConfig;
import com.phonebill.user.dto.*;
import com.phonebill.user.entity.AuthUserEntity;
import com.phonebill.user.entity.AuthUserSessionEntity;
import com.phonebill.user.exception.*;
import com.phonebill.user.repository.AuthUserRepository;
import com.phonebill.user.repository.AuthUserSessionRepository;
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.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.Optional;
/**
* 인증 서비스
* 로그인, 로그아웃, 토큰 갱신 등 인증 관련 기능을 담당
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class AuthService {
private final AuthUserRepository authUserRepository;
private final AuthUserSessionRepository authUserSessionRepository;
private final JwtService jwtService;
private final JwtConfig jwtConfig;
private final PasswordEncoder passwordEncoder;
private static final int MAX_LOGIN_ATTEMPTS = 5;
private static final long LOCKOUT_DURATION = 30 * 60 * 1000L; // 30분
/**
* 사용자 로그인
* @param request 로그인 요청 정보
* @return 로그인 응답 정보
*/
@Transactional
public LoginResponse login(LoginRequest request) {
// 사용자 조회
AuthUserEntity user = authUserRepository.findById(request.getUserId())
.orElseThrow(() -> UserNotFoundException.byUserId(request.getUserId()));
// 계정 상태 확인
validateAccountStatus(user);
// 비밀번호 검증
if (!verifyPassword(request.getPassword(), user.getPasswordHash(), user.getPasswordSalt())) {
handleLoginFailure(user);
throw InvalidCredentialsException.invalidPassword();
}
// 로그인 성공 처리
handleLoginSuccess(user);
// JWT 토큰 생성
String accessToken = jwtService.generateAccessToken(user);
String refreshToken = jwtService.generateRefreshToken(user);
// 세션 저장
saveUserSession(user, refreshToken);
log.info("사용자 로그인 성공: userId={}", user.getUserId());
return LoginResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.tokenType("Bearer")
.expiresIn((int) (jwtConfig.getAccessTokenValidity() / 1000)) // 초 단위로 변환
.userId(user.getUserId())
.customerId(user.getCustomerId())
.lineNumber(user.getLineNumber())
.build();
}
/**
* 토큰 갱신
* @param request 토큰 갱신 요청
* @return 새로운 토큰 정보
*/
@Transactional
public RefreshTokenResponse refreshToken(RefreshTokenRequest request) {
String refreshToken = request.getRefreshToken();
// Refresh Token 유효성 검증
if (!jwtService.validateToken(refreshToken) || !jwtService.isRefreshToken(refreshToken)) {
throw InvalidTokenException.invalid();
}
String userId = jwtService.getUserIdFromToken(refreshToken);
// 세션 확인
Optional<AuthUserSessionEntity> sessionOpt = authUserSessionRepository
.findByUserIdAndRefreshTokenAndIsActiveTrue(userId, refreshToken);
if (sessionOpt.isEmpty()) {
throw InvalidTokenException.invalid();
}
AuthUserSessionEntity session = sessionOpt.get();
// 사용자 조회
AuthUserEntity user = authUserRepository.findById(userId)
.orElseThrow(() -> UserNotFoundException.byUserId(userId));
// 계정 상태 확인
validateAccountStatus(user);
// 새로운 토큰 생성
String newAccessToken = jwtService.generateAccessToken(user);
String newRefreshToken = jwtService.generateRefreshToken(user);
// 기존 세션 비활성화 및 새 세션 생성
session.deactivate();
saveUserSession(user, newRefreshToken);
log.info("토큰 갱신 성공: userId={}", userId);
return RefreshTokenResponse.builder()
.accessToken(newAccessToken)
.refreshToken(newRefreshToken)
.tokenType("Bearer")
.expiresIn((int) (jwtConfig.getAccessTokenValidity() / 1000)) // 초 단위로 변환
.build();
}
/**
* 로그아웃
* @param userId 사용자 ID
* @param refreshToken Refresh Token
*/
@Transactional
public void logout(String userId, String refreshToken) {
// 세션 비활성화
Optional<AuthUserSessionEntity> sessionOpt = authUserSessionRepository
.findByUserIdAndRefreshTokenAndIsActiveTrue(userId, refreshToken);
sessionOpt.ifPresent(AuthUserSessionEntity::deactivate);
log.info("사용자 로그아웃: userId={}", userId);
}
/**
* 토큰 검증
* @param token 검증할 토큰
* @return 토큰 검증 결과
*/
public TokenVerifyResponse verifyToken(String token) {
try {
if (!jwtService.validateToken(token)) {
return TokenVerifyResponse.invalid();
}
String userId = jwtService.getUserIdFromToken(token);
String customerId = jwtService.getCustomerIdFromToken(token);
String lineNumber = jwtService.getLineNumberFromToken(token);
LocalDateTime expiresAt = jwtService.getExpirationDateFromToken(token);
return TokenVerifyResponse.valid(userId, customerId, lineNumber, expiresAt);
} catch (Exception e) {
log.warn("토큰 검증 실패: {}", e.getMessage());
return TokenVerifyResponse.invalid();
}
}
/**
* 계정 상태 검증
* @param user 사용자 정보
*/
private void validateAccountStatus(AuthUserEntity user) {
if (user.isAccountLocked()) {
throw AccountLockedException.create(user.getUserId(), user.getAccountLockedUntil());
}
if (!user.isAccountActive()) {
throw new RuntimeException("비활성 상태인 계정입니다.");
}
}
/**
* 비밀번호 검증
* @param plainPassword 평문 비밀번호
* @param hashedPassword 해시된 비밀번호
* @param salt 솔트
* @return 검증 결과
*/
private boolean verifyPassword(String plainPassword, String hashedPassword, String salt) {
String saltedPassword = plainPassword + salt;
return passwordEncoder.matches(saltedPassword, hashedPassword);
}
/**
* 로그인 실패 처리
* @param user 사용자 정보
*/
private void handleLoginFailure(AuthUserEntity user) {
user.incrementFailedLoginCount();
// 최대 로그인 시도 횟수 초과 시 계정 잠금
if (user.getFailedLoginCount() >= MAX_LOGIN_ATTEMPTS) {
user.lockAccount(LOCKOUT_DURATION);
log.warn("계정 잠금: userId={}, 시도횟수={}", user.getUserId(), user.getFailedLoginCount());
}
authUserRepository.save(user);
}
/**
* 로그인 성공 처리
* @param user 사용자 정보
*/
private void handleLoginSuccess(AuthUserEntity user) {
user.updateLastLogin();
authUserRepository.save(user);
}
/**
* 사용자 세션 저장
* @param user 사용자 정보
* @param refreshToken Refresh Token
*/
private void saveUserSession(AuthUserEntity user, String refreshToken) {
AuthUserSessionEntity session = AuthUserSessionEntity.builder()
.userId(user.getUserId())
.refreshToken(refreshToken)
.expiresAt(jwtService.getExpirationDateFromToken(refreshToken))
.isActive(true)
.build();
authUserSessionRepository.save(session);
}
/**
* 솔트 생성
* @return 랜덤 솔트
*/
private String generateSalt() {
SecureRandom random = new SecureRandom();
byte[] salt = new byte[32];
random.nextBytes(salt);
return Base64.getEncoder().encodeToString(salt);
}
/**
* 비밀번호 해싱
* @param plainPassword 평문 비밀번호
* @param salt 솔트
* @return 해시된 비밀번호
*/
private String hashPassword(String plainPassword, String salt) {
String saltedPassword = plainPassword + salt;
return passwordEncoder.encode(saltedPassword);
}
/**
* 비밀번호 변경
* @param userId 사용자 ID
* @param currentPassword 현재 비밀번호
* @param newPassword 새 비밀번호
*/
@Transactional
public void changePassword(String userId, String currentPassword, String newPassword) {
AuthUserEntity user = authUserRepository.findById(userId)
.orElseThrow(() -> UserNotFoundException.byUserId(userId));
// 현재 비밀번호 검증
if (!verifyPassword(currentPassword, user.getPasswordHash(), user.getPasswordSalt())) {
throw InvalidCredentialsException.invalidPassword();
}
// 새 비밀번호 해싱
String newSalt = generateSalt();
String newHashedPassword = hashPassword(newPassword, newSalt);
// 비밀번호 업데이트
user.updatePassword(newHashedPassword, newSalt);
authUserRepository.save(user);
// 모든 세션 무효화
authUserSessionRepository.deactivateAllUserSessions(userId);
log.info("비밀번호 변경 완료: userId={}", userId);
}
}
@@ -0,0 +1,247 @@
package com.phonebill.user.service;
import com.phonebill.user.config.JwtConfig;
import com.phonebill.user.entity.AuthUserEntity;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* JWT 토큰 관리 서비스
* JWT 토큰 생성, 검증, 파싱 등을 담당
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class JwtService {
private final JwtConfig jwtConfig;
/**
* Access Token 생성
* @param user 사용자 정보
* @return Access Token
*/
public String generateAccessToken(AuthUserEntity user) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", user.getUserId());
claims.put("customerId", user.getCustomerId());
claims.put("lineNumber", user.getLineNumber());
claims.put("type", "ACCESS");
return createToken(claims, user.getUserId(), jwtConfig.getAccessTokenValidity());
}
/**
* Refresh Token 생성
* @param user 사용자 정보
* @return Refresh Token
*/
public String generateRefreshToken(AuthUserEntity user) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", user.getUserId());
claims.put("type", "REFRESH");
return createToken(claims, user.getUserId(), jwtConfig.getRefreshTokenValidity());
}
/**
* JWT 토큰 생성
* @param claims 클레임 정보
* @param subject 주체 (사용자 ID)
* @param validity 유효시간 (milliseconds)
* @return JWT 토큰
*/
private String createToken(Map<String, Object> claims, String subject, long validity) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + validity);
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuer(jwtConfig.getIssuer())
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKey(), SignatureAlgorithm.HS512)
.compact();
}
/**
* 토큰에서 사용자 ID 추출
* @param token JWT 토큰
* @return 사용자 ID
*/
public String getUserIdFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims != null ? claims.getSubject() : null;
}
/**
* 토큰에서 고객 ID 추출
* @param token JWT 토큰
* @return 고객 ID
*/
public String getCustomerIdFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims != null ? (String) claims.get("customerId") : null;
}
/**
* 토큰에서 회선번호 추출
* @param token JWT 토큰
* @return 회선번호
*/
public String getLineNumberFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims != null ? (String) claims.get("lineNumber") : null;
}
/**
* 토큰에서 만료일 추출
* @param token JWT 토큰
* @return 만료일
*/
public LocalDateTime getExpirationDateFromToken(String token) {
Claims claims = getClaimsFromToken(token);
if (claims != null && claims.getExpiration() != null) {
return LocalDateTime.ofInstant(
claims.getExpiration().toInstant(),
ZoneId.systemDefault()
);
}
return null;
}
/**
* 토큰 유효성 검증
* @param token JWT 토큰
* @return 유효성 여부
*/
public boolean validateToken(String token) {
try {
Claims claims = getClaimsFromToken(token);
return claims != null && !isTokenExpired(claims);
} catch (Exception e) {
log.warn("JWT 토큰 검증 실패: {}", e.getMessage());
return false;
}
}
/**
* Access Token 여부 확인
* @param token JWT 토큰
* @return Access Token 여부
*/
public boolean isAccessToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims != null && "ACCESS".equals(claims.get("type"));
}
/**
* Refresh Token 여부 확인
* @param token JWT 토큰
* @return Refresh Token 여부
*/
public boolean isRefreshToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims != null && "REFRESH".equals(claims.get("type"));
}
/**
* 토큰에서 클레임 정보 추출
* @param token JWT 토큰
* @return 클레임 정보
*/
private Claims getClaimsFromToken(String token) {
try {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
} catch (ExpiredJwtException e) {
log.warn("JWT 토큰 만료: {}", e.getMessage());
throw e;
} catch (UnsupportedJwtException e) {
log.error("지원되지 않는 JWT 토큰: {}", e.getMessage());
throw e;
} catch (MalformedJwtException e) {
log.error("잘못된 형식의 JWT 토큰: {}", e.getMessage());
throw e;
} catch (SignatureException e) {
log.error("JWT 서명 검증 실패: {}", e.getMessage());
throw e;
} catch (IllegalArgumentException e) {
log.error("JWT 토큰이 비어있음: {}", e.getMessage());
throw e;
} catch (Exception e) {
log.error("JWT 토큰 파싱 실패: {}", e.getMessage());
return null;
}
}
/**
* 토큰 만료 여부 확인
* @param claims 클레임 정보
* @return 만료 여부
*/
private boolean isTokenExpired(Claims claims) {
Date expiration = claims.getExpiration();
return expiration.before(new Date());
}
/**
* JWT 서명 키 생성
* @return 서명 키
*/
private SecretKey getSigningKey() {
byte[] keyBytes = jwtConfig.getSecret().getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
/**
* 토큰 타입 확인
* @param token JWT 토큰
* @return 토큰 타입 ("ACCESS", "REFRESH", null)
*/
public String getTokenType(String token) {
Claims claims = getClaimsFromToken(token);
return claims != null ? (String) claims.get("type") : null;
}
/**
* 토큰 발급자 확인
* @param token JWT 토큰
* @return 발급자
*/
public String getIssuerFromToken(String token) {
Claims claims = getClaimsFromToken(token);
return claims != null ? claims.getIssuer() : null;
}
/**
* 토큰 발급 시간 확인
* @param token JWT 토큰
* @return 발급 시간
*/
public LocalDateTime getIssuedAtFromToken(String token) {
Claims claims = getClaimsFromToken(token);
if (claims != null && claims.getIssuedAt() != null) {
return LocalDateTime.ofInstant(
claims.getIssuedAt().toInstant(),
ZoneId.systemDefault()
);
}
return null;
}
}
@@ -0,0 +1,322 @@
package com.phonebill.user.service;
import com.phonebill.user.dto.*;
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.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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* 사용자 관리 서비스
* 사용자 정보 조회, 권한 관리 등을 담당
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {
private final AuthUserRepository authUserRepository;
private final AuthPermissionRepository authPermissionRepository;
private final AuthUserPermissionRepository authUserPermissionRepository;
/**
* 사용자 정보 조회
* @param userId 사용자 ID
* @return 사용자 정보
*/
public UserInfoResponse getUserInfo(String userId) {
AuthUserEntity user = authUserRepository.findById(userId)
.orElseThrow(() -> UserNotFoundException.byUserId(userId));
return UserInfoResponse.builder()
.userId(user.getUserId())
.customerId(user.getCustomerId())
.lineNumber(user.getLineNumber())
.accountStatus(user.getAccountStatus().name())
.lastLoginAt(user.getLastLoginAt())
.lastPasswordChangedAt(user.getLastPasswordChangedAt())
.build();
}
/**
* 고객 ID로 사용자 정보 조회
* @param customerId 고객 ID
* @return 사용자 정보
*/
public UserInfoResponse getUserInfoByCustomerId(String customerId) {
AuthUserEntity user = authUserRepository.findByCustomerId(customerId)
.orElseThrow(() -> UserNotFoundException.byCustomerId(customerId));
return UserInfoResponse.builder()
.userId(user.getUserId())
.customerId(user.getCustomerId())
.lineNumber(user.getLineNumber())
.accountStatus(user.getAccountStatus().name())
.lastLoginAt(user.getLastLoginAt())
.lastPasswordChangedAt(user.getLastPasswordChangedAt())
.build();
}
/**
* 회선번호로 사용자 정보 조회
* @param lineNumber 회선번호
* @return 사용자 정보
*/
public UserInfoResponse getUserInfoByLineNumber(String lineNumber) {
AuthUserEntity user = authUserRepository.findByLineNumber(lineNumber)
.orElseThrow(() -> UserNotFoundException.byLineNumber(lineNumber));
return UserInfoResponse.builder()
.userId(user.getUserId())
.customerId(user.getCustomerId())
.lineNumber(user.getLineNumber())
.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
* @param permissionCode 권한 코드
* @param grantedBy 권한 부여자
*/
@Transactional
public void grantPermission(String userId, String permissionCode, String grantedBy) {
// 사용자 존재 확인
if (!authUserRepository.existsByUserId(userId)) {
throw UserNotFoundException.byUserId(userId);
}
// 권한 조회
AuthPermissionEntity permission = authPermissionRepository
.findByPermissionCodeAndIsActiveTrue(permissionCode)
.orElseThrow(() -> new RuntimeException("존재하지 않는 권한입니다: " + permissionCode));
// 기존 권한 관계 확인
Optional<AuthUserPermissionEntity> existingPermission =
authUserPermissionRepository.findByUserIdAndPermissionId(userId, permission.getPermissionId());
if (existingPermission.isPresent()) {
// 기존 관계가 있으면 업데이트
authUserPermissionRepository.grantPermission(userId, permission.getPermissionId(), grantedBy);
} else {
// 새로운 권한 관계 생성
AuthUserPermissionEntity userPermission = AuthUserPermissionEntity.builder()
.userId(userId)
.permissionId(permission.getPermissionId())
.granted(true)
.grantedBy(grantedBy)
.build();
authUserPermissionRepository.save(userPermission);
}
log.info("권한 부여 완료: userId={}, permissionCode={}, grantedBy={}",
userId, permissionCode, grantedBy);
}
/**
* 권한 철회
* @param userId 사용자 ID
* @param permissionCode 권한 코드
*/
@Transactional
public void revokePermission(String userId, String permissionCode) {
// 사용자 존재 확인
if (!authUserRepository.existsByUserId(userId)) {
throw UserNotFoundException.byUserId(userId);
}
// 권한 조회
AuthPermissionEntity permission = authPermissionRepository
.findByPermissionCodeAndIsActiveTrue(permissionCode)
.orElseThrow(() -> new RuntimeException("존재하지 않는 권한입니다: " + permissionCode));
// 권한 철회
authUserPermissionRepository.revokePermission(userId, permission.getPermissionId());
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();
}
/**
* 계정 활성 상태 확인
* @param userId 사용자 ID
* @return 활성 상태 여부
*/
public boolean isAccountActive(String userId) {
AuthUserEntity user = authUserRepository.findById(userId)
.orElseThrow(() -> UserNotFoundException.byUserId(userId));
return user.isAccountActive();
}
/**
* 계정 잠금 해제 (관리자용)
* @param userId 사용자 ID
*/
@Transactional
public void unlockAccount(String userId) {
AuthUserEntity user = authUserRepository.findById(userId)
.orElseThrow(() -> UserNotFoundException.byUserId(userId));
user.unlockAccount();
authUserRepository.save(user);
log.info("계정 잠금 해제: userId={}", userId);
}
/**
* 권한 코드에 대한 설명 조회
* @param permissionCode 권한 코드
* @return 권한 설명
*/
private String getPermissionDescription(String permissionCode) {
Optional<AuthPermissionEntity> permission =
authPermissionRepository.findByPermissionCodeAndIsActiveTrue(permissionCode);
return permission.map(AuthPermissionEntity::getPermissionDescription).orElse("설명 없음");
}
}
@@ -0,0 +1,83 @@
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 # 개발환경에서는 빠른 처리를 위해 낮춤
@@ -0,0 +1,128 @@
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:
prometheus:
enabled: true
step: 60s
distribution:
percentiles-histogram:
http.server.requests: true
percentiles:
http.server.requests: 0.5, 0.9, 0.95, 0.99
@@ -0,0 +1,108 @@
spring:
application:
name: user-service
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
# JPA 공통 설정
jpa:
open-in-view: false
show-sql: false
properties:
hibernate:
format_sql: true
default_batch_fetch_size: 100
hibernate:
ddl-auto: ${DDL_AUTO:validate}
# Jackson 설정
jackson:
property-naming-strategy: SNAKE_CASE
default-property-inclusion: NON_NULL
time-zone: Asia/Seoul
# Redis 캐시 설정
cache:
type: redis
redis:
time-to-live: 1800000 # 30분
cache-null-values: false
# 서버 설정
server:
port: ${SERVER_PORT:8081}
servlet:
context-path: /api/v1
# JWT 토큰 설정
jwt:
secret: ${JWT_SECRET:Y2xhdWRlLWNvZGUtcGhvbmViaWxsLXNlY3JldC1rZXktZm9yLWF1dGgtc2VydmljZQ==}
access-token-validity: 1800000 # 30분 (milliseconds)
refresh-token-validity: 86400000 # 24시간 (milliseconds)
issuer: phonebill-auth-service
# 로깅 설정
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
# 액추에이터 설정
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
base-path: /actuator
endpoint:
health:
show-details: when_authorized
info:
env:
enabled: true
java:
enabled: true
metrics:
export:
prometheus:
enabled: true
# OpenAPI/Swagger 설정
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html
display-request-duration: true
groups-order: DESC
operationsSorter: method
disable-swagger-default-url: true
use-root-path: true
# Auth Service 특화 설정
auth:
login:
max-failed-attempts: 5
lockout-duration: 1800000 # 30분 (milliseconds)
session:
default-timeout: 1800000 # 30분 (milliseconds)
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