mirror of
https://github.com/cna-bootcamp/phonebill.git
synced 2026-06-12 19:49:10 +00:00
release
This commit is contained in:
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
+23
@@ -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);
|
||||
}
|
||||
}
|
||||
+162
@@ -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";
|
||||
}
|
||||
}
|
||||
+55
@@ -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);
|
||||
}
|
||||
+106
@@ -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);
|
||||
}
|
||||
+120
@@ -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
|
||||
Reference in New Issue
Block a user