mirror of
https://github.com/hwanny1128/HGZero.git
synced 2026-06-12 22:59:10 +00:00
develop
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
bootJar {
|
||||
archiveFileName = 'user.jar'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// LDAP
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-ldap'
|
||||
implementation 'org.springframework.ldap:spring-ldap-core'
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.unicorn.hgzero.user;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
|
||||
/**
|
||||
* User Service Application
|
||||
* 사용자 인증 서비스 메인 클래스
|
||||
*/
|
||||
@SpringBootApplication
|
||||
@ComponentScan(basePackages = {"com.unicorn.hgzero.user", "com.unicorn.hgzero.common"})
|
||||
public class UserApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(UserApplication.java, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.unicorn.hgzero.user.config;
|
||||
|
||||
import com.unicorn.hgzero.user.config.jwt.JwtAuthenticationFilter;
|
||||
import com.unicorn.hgzero.user.config.jwt.JwtTokenProvider;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Spring Security 설정
|
||||
* JWT 기반 인증 및 API 보안 설정
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
@Value("${cors.allowed-origins:http://localhost:*}")
|
||||
private String allowedOrigins;
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
// Public endpoints
|
||||
.requestMatchers("/api/v1/auth/login").permitAll()
|
||||
// Actuator endpoints
|
||||
.requestMatchers("/actuator/**").permitAll()
|
||||
// Swagger UI endpoints
|
||||
.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**").permitAll()
|
||||
// Health check
|
||||
.requestMatchers("/health").permitAll()
|
||||
// All other requests require authentication
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
|
||||
UsernamePasswordAuthenticationFilter.class)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
|
||||
// 환경변수에서 허용할 Origin 패턴 설정
|
||||
String[] origins = allowedOrigins.split(",");
|
||||
configuration.setAllowedOriginPatterns(Arrays.asList(origins));
|
||||
|
||||
// 허용할 HTTP 메소드
|
||||
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
||||
|
||||
// 허용할 헤더
|
||||
configuration.setAllowedHeaders(Arrays.asList(
|
||||
"Authorization", "Content-Type", "X-Requested-With", "Accept",
|
||||
"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"
|
||||
));
|
||||
|
||||
// 자격 증명 허용
|
||||
configuration.setAllowCredentials(true);
|
||||
|
||||
// Pre-flight 요청 캐시 시간
|
||||
configuration.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.unicorn.hgzero.user.config;
|
||||
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Contact;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.models.security.SecurityScheme;
|
||||
import io.swagger.v3.oas.models.servers.Server;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Swagger/OpenAPI 설정
|
||||
* User Service API 문서화를 위한 설정
|
||||
*/
|
||||
@Configuration
|
||||
public class SwaggerConfig {
|
||||
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
return new OpenAPI()
|
||||
.info(apiInfo())
|
||||
.addServersItem(new Server()
|
||||
.url("http://localhost:8080")
|
||||
.description("Local Development"))
|
||||
.addServersItem(new Server()
|
||||
.url("{protocol}://{host}:{port}")
|
||||
.description("Custom Server")
|
||||
.variables(new io.swagger.v3.oas.models.servers.ServerVariables()
|
||||
.addServerVariable("protocol", new io.swagger.v3.oas.models.servers.ServerVariable()
|
||||
._default("http")
|
||||
.description("Protocol (http or https)")
|
||||
.addEnumItem("http")
|
||||
.addEnumItem("https"))
|
||||
.addServerVariable("host", new io.swagger.v3.oas.models.servers.ServerVariable()
|
||||
._default("localhost")
|
||||
.description("Server host"))
|
||||
.addServerVariable("port", new io.swagger.v3.oas.models.servers.ServerVariable()
|
||||
._default("8080")
|
||||
.description("Server port"))))
|
||||
.addSecurityItem(new SecurityRequirement().addList("Bearer Authentication"))
|
||||
.components(new Components()
|
||||
.addSecuritySchemes("Bearer Authentication", createAPIKeyScheme()));
|
||||
}
|
||||
|
||||
private Info apiInfo() {
|
||||
return new Info()
|
||||
.title("User Service API")
|
||||
.description("사용자 인증 및 JWT 토큰 관리 API")
|
||||
.version("1.0.0")
|
||||
.contact(new Contact()
|
||||
.name("HGZero Development Team")
|
||||
.email("dev@hgzero.com"));
|
||||
}
|
||||
|
||||
private SecurityScheme createAPIKeyScheme() {
|
||||
return new SecurityScheme()
|
||||
.type(SecurityScheme.Type.HTTP)
|
||||
.bearerFormat("JWT")
|
||||
.scheme("bearer");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.unicorn.hgzero.user.config.jwt;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
|
||||
/**
|
||||
* JWT 인증 필터
|
||||
* HTTP 요청에서 JWT 토큰을 추출하여 인증을 수행
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
String token = jwtTokenProvider.resolveToken(request);
|
||||
|
||||
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
|
||||
String userId = jwtTokenProvider.getUserId(token);
|
||||
String username = null;
|
||||
String authority = null;
|
||||
|
||||
try {
|
||||
username = jwtTokenProvider.getUsername(token);
|
||||
} catch (Exception e) {
|
||||
log.debug("JWT에 username 클레임이 없음: {}", e.getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
authority = jwtTokenProvider.getAuthority(token);
|
||||
} catch (Exception e) {
|
||||
log.debug("JWT에 authority 클레임이 없음: {}", e.getMessage());
|
||||
}
|
||||
|
||||
if (StringUtils.hasText(userId)) {
|
||||
// UserPrincipal 객체 생성 (username과 authority가 없어도 동작)
|
||||
UserPrincipal userPrincipal = UserPrincipal.builder()
|
||||
.userId(userId)
|
||||
.username(username != null ? username : "unknown")
|
||||
.authority(authority != null ? authority : "USER")
|
||||
.build();
|
||||
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(
|
||||
userPrincipal,
|
||||
null,
|
||||
Collections.singletonList(new SimpleGrantedAuthority(authority != null ? authority : "USER"))
|
||||
);
|
||||
|
||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
|
||||
log.debug("인증된 사용자: {} ({})", userPrincipal.getUsername(), userId);
|
||||
}
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String path = request.getRequestURI();
|
||||
return path.startsWith("/actuator") ||
|
||||
path.startsWith("/swagger-ui") ||
|
||||
path.startsWith("/v3/api-docs") ||
|
||||
path.equals("/health");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.unicorn.hgzero.user.config.jwt;
|
||||
|
||||
import io.jsonwebtoken.*;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* JWT 토큰 제공자
|
||||
* JWT 토큰의 생성, 검증, 파싱을 담당
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class JwtTokenProvider {
|
||||
|
||||
private final SecretKey secretKey;
|
||||
private final long tokenValidityInMilliseconds;
|
||||
|
||||
public JwtTokenProvider(@Value("${jwt.secret}") String secret,
|
||||
@Value("${jwt.access-token-validity:3600}") long tokenValidityInSeconds) {
|
||||
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||
this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 요청에서 JWT 토큰 추출
|
||||
*/
|
||||
public String resolveToken(HttpServletRequest request) {
|
||||
String bearerToken = request.getHeader("Authorization");
|
||||
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
|
||||
return bearerToken.substring(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰 유효성 검증
|
||||
*/
|
||||
public boolean validateToken(String token) {
|
||||
try {
|
||||
Jwts.parser()
|
||||
.setSigningKey(secretKey)
|
||||
.build()
|
||||
.parseClaimsJws(token);
|
||||
return true;
|
||||
} catch (SecurityException | MalformedJwtException e) {
|
||||
log.debug("Invalid JWT signature: {}", e.getMessage());
|
||||
} catch (ExpiredJwtException e) {
|
||||
log.debug("Expired JWT token: {}", e.getMessage());
|
||||
} catch (UnsupportedJwtException e) {
|
||||
log.debug("Unsupported JWT token: {}", e.getMessage());
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.debug("JWT token compact of handler are invalid: {}", e.getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰에서 사용자 ID 추출
|
||||
*/
|
||||
public String getUserId(String token) {
|
||||
Claims claims = Jwts.parser()
|
||||
.setSigningKey(secretKey)
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
|
||||
return claims.getSubject();
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰에서 사용자명 추출
|
||||
*/
|
||||
public String getUsername(String token) {
|
||||
Claims claims = Jwts.parser()
|
||||
.setSigningKey(secretKey)
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
|
||||
return claims.get("username", String.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 토큰에서 권한 정보 추출
|
||||
*/
|
||||
public String getAuthority(String token) {
|
||||
Claims claims = Jwts.parser()
|
||||
.setSigningKey(secretKey)
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
|
||||
return claims.get("authority", String.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 만료 시간 확인
|
||||
*/
|
||||
public boolean isTokenExpired(String token) {
|
||||
try {
|
||||
Claims claims = Jwts.parser()
|
||||
.setSigningKey(secretKey)
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
|
||||
return claims.getExpiration().before(new Date());
|
||||
} catch (Exception e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰에서 만료 시간 추출
|
||||
*/
|
||||
public Date getExpirationDate(String token) {
|
||||
Claims claims = Jwts.parser()
|
||||
.setSigningKey(secretKey)
|
||||
.build()
|
||||
.parseClaimsJws(token)
|
||||
.getBody();
|
||||
|
||||
return claims.getExpiration();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.unicorn.hgzero.user.config.jwt;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* 인증된 사용자 정보
|
||||
* JWT 토큰에서 추출된 사용자 정보를 담는 Principal 객체
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@RequiredArgsConstructor
|
||||
public class UserPrincipal {
|
||||
|
||||
/**
|
||||
* 사용자 고유 ID
|
||||
*/
|
||||
private final String userId;
|
||||
|
||||
/**
|
||||
* 사용자명
|
||||
*/
|
||||
private final String username;
|
||||
|
||||
/**
|
||||
* 사용자 권한
|
||||
*/
|
||||
private final String authority;
|
||||
|
||||
/**
|
||||
* 사용자 ID 반환 (별칭)
|
||||
*/
|
||||
public String getName() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 권한 여부 확인
|
||||
*/
|
||||
public boolean isAdmin() {
|
||||
return "ADMIN".equals(authority);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일반 사용자 권한 여부 확인
|
||||
*/
|
||||
public boolean isUser() {
|
||||
return "USER".equals(authority) || authority == null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.unicorn.hgzero.user.controller;
|
||||
|
||||
import com.unicorn.hgzero.common.dto.ApiResponse;
|
||||
import com.unicorn.hgzero.user.config.jwt.UserPrincipal;
|
||||
import com.unicorn.hgzero.user.dto.*;
|
||||
import com.unicorn.hgzero.user.service.UserService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
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.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* 사용자 인증 Controller
|
||||
* 로그인, 토큰 갱신, 로그아웃, 토큰 검증 API 제공
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/auth")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "User Authentication", description = "사용자 인증 API")
|
||||
public class UserController {
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
/**
|
||||
* 사용자 로그인
|
||||
* LDAP 인증 수행 후 JWT 토큰 발급
|
||||
*
|
||||
* @param request 로그인 요청 정보 (userId, password)
|
||||
* @return 로그인 응답 (Access Token, Refresh Token, 사용자 정보)
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
@Operation(
|
||||
summary = "사용자 로그인",
|
||||
description = "LDAP 인증 수행 후 JWT Access Token과 Refresh Token을 발급합니다."
|
||||
)
|
||||
public ResponseEntity<ApiResponse<LoginResponse>> login(
|
||||
@Valid @RequestBody LoginRequest request) {
|
||||
|
||||
log.info("로그인 요청: userId={}", request.getUserId());
|
||||
|
||||
LoginResponse response = userService.login(request);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success("로그인 성공", response));
|
||||
}
|
||||
|
||||
/**
|
||||
* Access Token 갱신
|
||||
* Refresh Token을 사용하여 새로운 Access Token 발급
|
||||
*
|
||||
* @param request Refresh Token 요청 정보
|
||||
* @return 새로운 Access Token
|
||||
*/
|
||||
@PostMapping("/refresh")
|
||||
@Operation(
|
||||
summary = "Access Token 갱신",
|
||||
description = "Refresh Token을 사용하여 새로운 Access Token을 발급합니다."
|
||||
)
|
||||
public ResponseEntity<ApiResponse<RefreshTokenResponse>> refresh(
|
||||
@Valid @RequestBody RefreshTokenRequest request) {
|
||||
|
||||
log.info("토큰 갱신 요청");
|
||||
|
||||
RefreshTokenResponse response = userService.refresh(request);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success("토큰 갱신 성공", response));
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃
|
||||
* Refresh Token 삭제
|
||||
*
|
||||
* @param request 로그아웃 요청 정보 (선택적 Refresh Token)
|
||||
* @param userPrincipal 인증된 사용자 정보
|
||||
* @return 로그아웃 성공 메시지
|
||||
*/
|
||||
@PostMapping("/logout")
|
||||
@Operation(
|
||||
summary = "로그아웃",
|
||||
description = "Refresh Token을 삭제하고 로그아웃합니다."
|
||||
)
|
||||
public ResponseEntity<ApiResponse<Void>> logout(
|
||||
@RequestBody(required = false) LogoutRequest request,
|
||||
@AuthenticationPrincipal UserPrincipal userPrincipal) {
|
||||
|
||||
log.info("로그아웃 요청: userId={}", userPrincipal.getUserId());
|
||||
|
||||
userService.logout(request, userPrincipal.getUserId());
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success("로그아웃 성공", null));
|
||||
}
|
||||
|
||||
/**
|
||||
* Access Token 검증
|
||||
* 토큰 유효성 검증 및 사용자 정보 반환
|
||||
*
|
||||
* @param authorization Authorization 헤더 (Bearer {token})
|
||||
* @return 토큰 검증 결과 및 사용자 정보
|
||||
*/
|
||||
@GetMapping("/validate")
|
||||
@Operation(
|
||||
summary = "Access Token 검증",
|
||||
description = "Access Token의 유효성을 검증하고 사용자 정보를 반환합니다."
|
||||
)
|
||||
public ResponseEntity<ApiResponse<TokenValidateResponse>> validate(
|
||||
@Parameter(description = "Bearer {token}", required = true)
|
||||
@RequestHeader("Authorization") String authorization) {
|
||||
|
||||
log.info("토큰 검증 요청");
|
||||
|
||||
// Bearer 제거
|
||||
String token = authorization.replace("Bearer ", "");
|
||||
|
||||
TokenValidateResponse response = userService.validateToken(token);
|
||||
|
||||
if (response.getValid()) {
|
||||
return ResponseEntity.ok(ApiResponse.success("토큰 검증 성공", response));
|
||||
} else {
|
||||
return ResponseEntity.ok(ApiResponse.success("토큰 검증 실패", response));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.unicorn.hgzero.user.domain;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 사용자 도메인 모델
|
||||
* 사용자 인증 및 계정 정보를 표현
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class User {
|
||||
|
||||
/**
|
||||
* 사용자 ID
|
||||
*/
|
||||
private String userId;
|
||||
|
||||
/**
|
||||
* 사용자 이름
|
||||
*/
|
||||
private String username;
|
||||
|
||||
/**
|
||||
* 이메일
|
||||
*/
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 권한 (USER, ADMIN)
|
||||
*/
|
||||
private String authority;
|
||||
|
||||
/**
|
||||
* 계정 잠금 여부
|
||||
*/
|
||||
private Boolean locked;
|
||||
|
||||
/**
|
||||
* 로그인 실패 횟수
|
||||
*/
|
||||
private Integer failedLoginAttempts;
|
||||
|
||||
/**
|
||||
* 마지막 로그인 일시
|
||||
*/
|
||||
private LocalDateTime lastLoginAt;
|
||||
|
||||
/**
|
||||
* 계정 잠금 일시
|
||||
*/
|
||||
private LocalDateTime lockedAt;
|
||||
|
||||
/**
|
||||
* 계정 잠금 해제
|
||||
*/
|
||||
public void unlock() {
|
||||
this.locked = false;
|
||||
this.failedLoginAttempts = 0;
|
||||
this.lockedAt = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 실패 기록
|
||||
* 5회 실패 시 계정 잠금
|
||||
*/
|
||||
public void recordLoginFailure() {
|
||||
this.failedLoginAttempts++;
|
||||
if (this.failedLoginAttempts >= 5) {
|
||||
this.locked = true;
|
||||
this.lockedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 성공 기록
|
||||
*/
|
||||
public void recordLoginSuccess() {
|
||||
this.failedLoginAttempts = 0;
|
||||
this.lastLoginAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정 잠금 여부 확인
|
||||
* 30분 경과 시 자동 해제
|
||||
*/
|
||||
public boolean isLocked() {
|
||||
if (this.locked && this.lockedAt != null) {
|
||||
// 30분 경과 시 자동 해제
|
||||
if (LocalDateTime.now().isAfter(this.lockedAt.plusMinutes(30))) {
|
||||
this.unlock();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.unicorn.hgzero.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 LoginRequest {
|
||||
|
||||
/**
|
||||
* 사용자 ID
|
||||
*/
|
||||
@NotBlank(message = "사용자 ID는 필수입니다.")
|
||||
private String userId;
|
||||
|
||||
/**
|
||||
* 비밀번호
|
||||
*/
|
||||
@NotBlank(message = "비밀번호는 필수입니다.")
|
||||
private String password;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.unicorn.hgzero.user.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 로그인 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class LoginResponse {
|
||||
|
||||
/**
|
||||
* Access Token
|
||||
*/
|
||||
private String accessToken;
|
||||
|
||||
/**
|
||||
* Refresh Token
|
||||
*/
|
||||
private String refreshToken;
|
||||
|
||||
/**
|
||||
* 토큰 타입 (Bearer)
|
||||
*/
|
||||
@Builder.Default
|
||||
private String tokenType = "Bearer";
|
||||
|
||||
/**
|
||||
* Access Token 유효 기간 (초)
|
||||
*/
|
||||
private Long accessTokenValidity;
|
||||
|
||||
/**
|
||||
* Refresh Token 유효 기간 (초)
|
||||
*/
|
||||
private Long refreshTokenValidity;
|
||||
|
||||
/**
|
||||
* 사용자 정보
|
||||
*/
|
||||
private UserInfo user;
|
||||
|
||||
/**
|
||||
* 사용자 정보 내부 클래스
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class UserInfo {
|
||||
/**
|
||||
* 사용자 ID
|
||||
*/
|
||||
private String userId;
|
||||
|
||||
/**
|
||||
* 사용자 이름
|
||||
*/
|
||||
private String username;
|
||||
|
||||
/**
|
||||
* 이메일
|
||||
*/
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 권한
|
||||
*/
|
||||
private String authority;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.unicorn.hgzero.user.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 로그아웃 요청 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class LogoutRequest {
|
||||
|
||||
/**
|
||||
* Refresh Token (선택사항)
|
||||
* 제공되면 해당 Refresh Token을 명시적으로 삭제
|
||||
*/
|
||||
private String refreshToken;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.unicorn.hgzero.user.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Refresh Token 요청 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class RefreshTokenRequest {
|
||||
|
||||
/**
|
||||
* Refresh Token
|
||||
*/
|
||||
@NotBlank(message = "Refresh Token은 필수입니다.")
|
||||
private String refreshToken;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.unicorn.hgzero.user.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Refresh Token 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class RefreshTokenResponse {
|
||||
|
||||
/**
|
||||
* 새로 발급된 Access Token
|
||||
*/
|
||||
private String accessToken;
|
||||
|
||||
/**
|
||||
* 토큰 타입 (Bearer)
|
||||
*/
|
||||
@Builder.Default
|
||||
private String tokenType = "Bearer";
|
||||
|
||||
/**
|
||||
* Access Token 유효 기간 (초)
|
||||
*/
|
||||
private Long accessTokenValidity;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.unicorn.hgzero.user.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 토큰 검증 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TokenValidateResponse {
|
||||
|
||||
/**
|
||||
* 토큰 유효 여부
|
||||
*/
|
||||
private Boolean valid;
|
||||
|
||||
/**
|
||||
* 사용자 ID
|
||||
*/
|
||||
private String userId;
|
||||
|
||||
/**
|
||||
* 사용자 이름
|
||||
*/
|
||||
private String username;
|
||||
|
||||
/**
|
||||
* 이메일
|
||||
*/
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 권한
|
||||
*/
|
||||
private String authority;
|
||||
|
||||
/**
|
||||
* 토큰 만료 여부
|
||||
*/
|
||||
private Boolean expired;
|
||||
|
||||
/**
|
||||
* 에러 메시지 (유효하지 않은 경우)
|
||||
*/
|
||||
private String errorMessage;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.unicorn.hgzero.user.repository.entity;
|
||||
|
||||
import com.unicorn.hgzero.common.entity.BaseTimeEntity;
|
||||
import com.unicorn.hgzero.user.domain.User;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 사용자 Entity
|
||||
* 사용자 인증 정보를 데이터베이스에 저장
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class UserEntity extends BaseTimeEntity {
|
||||
|
||||
/**
|
||||
* 사용자 ID (Primary Key)
|
||||
*/
|
||||
@Id
|
||||
@Column(name = "user_id", length = 50)
|
||||
private String userId;
|
||||
|
||||
/**
|
||||
* 사용자 이름
|
||||
*/
|
||||
@Column(name = "username", length = 100, nullable = false)
|
||||
private String username;
|
||||
|
||||
/**
|
||||
* 이메일
|
||||
*/
|
||||
@Column(name = "email", length = 100, nullable = false)
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 권한 (USER, ADMIN)
|
||||
*/
|
||||
@Column(name = "authority", length = 20, nullable = false)
|
||||
@Builder.Default
|
||||
private String authority = "USER";
|
||||
|
||||
/**
|
||||
* 계정 잠금 여부
|
||||
*/
|
||||
@Column(name = "locked", nullable = false)
|
||||
@Builder.Default
|
||||
private Boolean locked = false;
|
||||
|
||||
/**
|
||||
* 로그인 실패 횟수
|
||||
*/
|
||||
@Column(name = "failed_login_attempts", nullable = false)
|
||||
@Builder.Default
|
||||
private Integer failedLoginAttempts = 0;
|
||||
|
||||
/**
|
||||
* 마지막 로그인 일시
|
||||
*/
|
||||
@Column(name = "last_login_at")
|
||||
private LocalDateTime lastLoginAt;
|
||||
|
||||
/**
|
||||
* 계정 잠금 일시
|
||||
*/
|
||||
@Column(name = "locked_at")
|
||||
private LocalDateTime lockedAt;
|
||||
|
||||
/**
|
||||
* Entity를 Domain으로 변환
|
||||
*
|
||||
* @return User 도메인 객체
|
||||
*/
|
||||
public User toDomain() {
|
||||
return User.builder()
|
||||
.userId(this.userId)
|
||||
.username(this.username)
|
||||
.email(this.email)
|
||||
.authority(this.authority)
|
||||
.locked(this.locked)
|
||||
.failedLoginAttempts(this.failedLoginAttempts)
|
||||
.lastLoginAt(this.lastLoginAt)
|
||||
.lockedAt(this.lockedAt)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain을 Entity로 변환
|
||||
*
|
||||
* @param user User 도메인 객체
|
||||
* @return UserEntity
|
||||
*/
|
||||
public static UserEntity fromDomain(User user) {
|
||||
return UserEntity.builder()
|
||||
.userId(user.getUserId())
|
||||
.username(user.getUsername())
|
||||
.email(user.getEmail())
|
||||
.authority(user.getAuthority())
|
||||
.locked(user.getLocked())
|
||||
.failedLoginAttempts(user.getFailedLoginAttempts())
|
||||
.lastLoginAt(user.getLastLoginAt())
|
||||
.lockedAt(user.getLockedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 계정 잠금 해제
|
||||
*/
|
||||
public void unlock() {
|
||||
this.locked = false;
|
||||
this.failedLoginAttempts = 0;
|
||||
this.lockedAt = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 실패 기록
|
||||
*/
|
||||
public void recordLoginFailure() {
|
||||
this.failedLoginAttempts++;
|
||||
if (this.failedLoginAttempts >= 5) {
|
||||
this.locked = true;
|
||||
this.lockedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 성공 기록
|
||||
*/
|
||||
public void recordLoginSuccess() {
|
||||
this.failedLoginAttempts = 0;
|
||||
this.lastLoginAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.unicorn.hgzero.user.repository.jpa;
|
||||
|
||||
import com.unicorn.hgzero.user.repository.entity.UserEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 사용자 Repository
|
||||
* 사용자 데이터 접근을 위한 JPA Repository
|
||||
*/
|
||||
@Repository
|
||||
public interface UserRepository extends JpaRepository<UserEntity, String> {
|
||||
|
||||
/**
|
||||
* 이메일로 사용자 조회
|
||||
*
|
||||
* @param email 이메일
|
||||
* @return Optional<UserEntity>
|
||||
*/
|
||||
Optional<UserEntity> findByEmail(String email);
|
||||
|
||||
/**
|
||||
* 사용자명으로 사용자 조회
|
||||
*
|
||||
* @param username 사용자명
|
||||
* @return Optional<UserEntity>
|
||||
*/
|
||||
Optional<UserEntity> findByUsername(String username);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.unicorn.hgzero.user.service;
|
||||
|
||||
import com.unicorn.hgzero.user.dto.*;
|
||||
|
||||
/**
|
||||
* 사용자 인증 서비스 인터페이스
|
||||
*/
|
||||
public interface UserService {
|
||||
|
||||
/**
|
||||
* 사용자 로그인
|
||||
* LDAP 인증 수행 후 JWT 토큰 발급
|
||||
*
|
||||
* @param request 로그인 요청 정보
|
||||
* @return 로그인 응답 (Access Token, Refresh Token)
|
||||
*/
|
||||
LoginResponse login(LoginRequest request);
|
||||
|
||||
/**
|
||||
* Access Token 갱신
|
||||
* Refresh Token을 사용하여 새로운 Access Token 발급
|
||||
*
|
||||
* @param request Refresh Token 요청 정보
|
||||
* @return 새로운 Access Token
|
||||
*/
|
||||
RefreshTokenResponse refresh(RefreshTokenRequest request);
|
||||
|
||||
/**
|
||||
* 로그아웃
|
||||
* Refresh Token 삭제
|
||||
*
|
||||
* @param request 로그아웃 요청 정보
|
||||
* @param userId 사용자 ID
|
||||
*/
|
||||
void logout(LogoutRequest request, String userId);
|
||||
|
||||
/**
|
||||
* Access Token 검증
|
||||
* 토큰 유효성 검증 및 사용자 정보 반환
|
||||
*
|
||||
* @param token Access Token
|
||||
* @return 토큰 검증 결과
|
||||
*/
|
||||
TokenValidateResponse validateToken(String token);
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package com.unicorn.hgzero.user.service;
|
||||
|
||||
import com.unicorn.hgzero.common.exception.BusinessException;
|
||||
import com.unicorn.hgzero.common.exception.ErrorCode;
|
||||
import com.unicorn.hgzero.user.config.jwt.JwtTokenProvider;
|
||||
import com.unicorn.hgzero.user.domain.User;
|
||||
import com.unicorn.hgzero.user.dto.*;
|
||||
import com.unicorn.hgzero.user.repository.entity.UserEntity;
|
||||
import com.unicorn.hgzero.user.repository.jpa.UserRepository;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.ldap.core.LdapTemplate;
|
||||
import org.springframework.ldap.filter.EqualsFilter;
|
||||
import org.springframework.ldap.filter.Filter;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.naming.directory.DirContext;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Date;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
|
||||
/**
|
||||
* 사용자 인증 서비스 구현체
|
||||
* LDAP 인증, JWT 토큰 발급, Refresh Token 관리 기능 제공
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class UserServiceImpl implements UserService {
|
||||
|
||||
private final LdapTemplate ldapTemplate;
|
||||
private final UserRepository userRepository;
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final RedisTemplate<String, String> redisTemplate;
|
||||
|
||||
@Value("${jwt.secret}")
|
||||
private String jwtSecret;
|
||||
|
||||
@Value("${jwt.access-token-validity:3600}")
|
||||
private Long accessTokenValidity;
|
||||
|
||||
@Value("${jwt.refresh-token-validity:604800}")
|
||||
private Long refreshTokenValidity;
|
||||
|
||||
@Value("${spring.ldap.user-dn-pattern:uid={0},ou=people}")
|
||||
private String userDnPattern;
|
||||
|
||||
/**
|
||||
* 사용자 로그인
|
||||
* LDAP 인증 수행 후 JWT 토큰 발급
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public LoginResponse login(LoginRequest request) {
|
||||
log.info("로그인 시도: userId={}", request.getUserId());
|
||||
|
||||
// 사용자 조회 또는 생성
|
||||
UserEntity userEntity = userRepository.findById(request.getUserId())
|
||||
.orElse(null);
|
||||
|
||||
// 계정 잠금 확인
|
||||
if (userEntity != null && userEntity.getLocked()) {
|
||||
// 30분 경과 시 자동 해제
|
||||
if (userEntity.getLockedAt() != null &&
|
||||
LocalDateTime.now().isAfter(userEntity.getLockedAt().plusMinutes(30))) {
|
||||
userEntity.unlock();
|
||||
userRepository.save(userEntity);
|
||||
} else {
|
||||
log.warn("계정 잠금: userId={}", request.getUserId());
|
||||
throw new BusinessException(ErrorCode.ACCOUNT_LOCKED);
|
||||
}
|
||||
}
|
||||
|
||||
// LDAP 인증
|
||||
try {
|
||||
authenticateWithLdap(request.getUserId(), request.getPassword());
|
||||
log.info("LDAP 인증 성공: userId={}", request.getUserId());
|
||||
} catch (Exception e) {
|
||||
log.error("LDAP 인증 실패: userId={}, error={}", request.getUserId(), e.getMessage());
|
||||
|
||||
// 로그인 실패 기록
|
||||
if (userEntity != null) {
|
||||
userEntity.recordLoginFailure();
|
||||
userRepository.save(userEntity);
|
||||
}
|
||||
|
||||
throw new BusinessException(ErrorCode.AUTHENTICATION_FAILED, "인증에 실패했습니다.");
|
||||
}
|
||||
|
||||
// 사용자 정보 조회 또는 생성
|
||||
if (userEntity == null) {
|
||||
// LDAP에서 추가 정보 조회 (실제 환경에서는 LDAP에서 이메일 등을 가져와야 함)
|
||||
userEntity = UserEntity.builder()
|
||||
.userId(request.getUserId())
|
||||
.username(request.getUserId()) // LDAP에서 가져온 실제 이름 사용
|
||||
.email(request.getUserId() + "@example.com") // LDAP에서 가져온 실제 이메일 사용
|
||||
.authority("USER")
|
||||
.locked(false)
|
||||
.failedLoginAttempts(0)
|
||||
.build();
|
||||
}
|
||||
|
||||
// 로그인 성공 기록
|
||||
userEntity.recordLoginSuccess();
|
||||
userEntity = userRepository.save(userEntity);
|
||||
|
||||
User user = userEntity.toDomain();
|
||||
|
||||
// JWT 토큰 생성
|
||||
String accessToken = generateAccessToken(user);
|
||||
String refreshToken = generateRefreshToken(user);
|
||||
|
||||
// Refresh Token을 Redis에 저장
|
||||
saveRefreshToken(user.getUserId(), refreshToken);
|
||||
|
||||
log.info("로그인 성공: userId={}", request.getUserId());
|
||||
|
||||
return LoginResponse.builder()
|
||||
.accessToken(accessToken)
|
||||
.refreshToken(refreshToken)
|
||||
.tokenType("Bearer")
|
||||
.accessTokenValidity(accessTokenValidity)
|
||||
.refreshTokenValidity(refreshTokenValidity)
|
||||
.user(LoginResponse.UserInfo.builder()
|
||||
.userId(user.getUserId())
|
||||
.username(user.getUsername())
|
||||
.email(user.getEmail())
|
||||
.authority(user.getAuthority())
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Access Token 갱신
|
||||
*/
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public RefreshTokenResponse refresh(RefreshTokenRequest request) {
|
||||
log.info("토큰 갱신 시도");
|
||||
|
||||
// Refresh Token 검증
|
||||
if (!jwtTokenProvider.validateToken(request.getRefreshToken())) {
|
||||
log.warn("유효하지 않은 Refresh Token");
|
||||
throw new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN);
|
||||
}
|
||||
|
||||
// Refresh Token에서 사용자 ID 추출
|
||||
String userId = jwtTokenProvider.getUserId(request.getRefreshToken());
|
||||
|
||||
// Redis에서 저장된 Refresh Token 조회
|
||||
String savedRefreshToken = getRefreshToken(userId);
|
||||
if (savedRefreshToken == null || !savedRefreshToken.equals(request.getRefreshToken())) {
|
||||
log.warn("Refresh Token이 일치하지 않음: userId={}", userId);
|
||||
throw new BusinessException(ErrorCode.INVALID_REFRESH_TOKEN);
|
||||
}
|
||||
|
||||
// 사용자 조회
|
||||
UserEntity userEntity = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND, "사용자를 찾을 수 없습니다."));
|
||||
|
||||
User user = userEntity.toDomain();
|
||||
|
||||
// 새로운 Access Token 생성
|
||||
String newAccessToken = generateAccessToken(user);
|
||||
|
||||
log.info("토큰 갱신 성공: userId={}", userId);
|
||||
|
||||
return RefreshTokenResponse.builder()
|
||||
.accessToken(newAccessToken)
|
||||
.tokenType("Bearer")
|
||||
.accessTokenValidity(accessTokenValidity)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그아웃
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void logout(LogoutRequest request, String userId) {
|
||||
log.info("로그아웃: userId={}", userId);
|
||||
|
||||
// Redis에서 Refresh Token 삭제
|
||||
deleteRefreshToken(userId);
|
||||
|
||||
log.info("로그아웃 완료: userId={}", userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Access Token 검증
|
||||
*/
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public TokenValidateResponse validateToken(String token) {
|
||||
log.debug("토큰 검증 시작");
|
||||
|
||||
try {
|
||||
// 토큰 유효성 검증
|
||||
if (!jwtTokenProvider.validateToken(token)) {
|
||||
return TokenValidateResponse.builder()
|
||||
.valid(false)
|
||||
.expired(jwtTokenProvider.isTokenExpired(token))
|
||||
.errorMessage("유효하지 않은 토큰입니다.")
|
||||
.build();
|
||||
}
|
||||
|
||||
// 토큰에서 사용자 정보 추출
|
||||
String userId = jwtTokenProvider.getUserId(token);
|
||||
String username = jwtTokenProvider.getUsername(token);
|
||||
String authority = jwtTokenProvider.getAuthority(token);
|
||||
|
||||
// 사용자 조회
|
||||
UserEntity userEntity = userRepository.findById(userId)
|
||||
.orElse(null);
|
||||
|
||||
return TokenValidateResponse.builder()
|
||||
.valid(true)
|
||||
.userId(userId)
|
||||
.username(username)
|
||||
.email(userEntity != null ? userEntity.getEmail() : null)
|
||||
.authority(authority)
|
||||
.expired(false)
|
||||
.build();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("토큰 검증 실패: error={}", e.getMessage());
|
||||
return TokenValidateResponse.builder()
|
||||
.valid(false)
|
||||
.expired(false)
|
||||
.errorMessage("토큰 검증 중 오류가 발생했습니다.")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LDAP 인증 수행
|
||||
*/
|
||||
private void authenticateWithLdap(String userId, String password) {
|
||||
// LDAP DN 생성
|
||||
String userDn = userDnPattern.replace("{0}", userId);
|
||||
|
||||
// LDAP 인증
|
||||
Filter filter = new EqualsFilter("uid", userId);
|
||||
boolean authenticated = ldapTemplate.authenticate("", filter.encode(), password);
|
||||
|
||||
if (!authenticated) {
|
||||
throw new BusinessException(ErrorCode.AUTHENTICATION_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Access Token 생성
|
||||
*/
|
||||
private String generateAccessToken(User user) {
|
||||
Date now = new Date();
|
||||
Date expiryDate = new Date(now.getTime() + accessTokenValidity * 1000);
|
||||
|
||||
SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
return Jwts.builder()
|
||||
.setSubject(user.getUserId())
|
||||
.claim("username", user.getUsername())
|
||||
.claim("authority", user.getAuthority())
|
||||
.setIssuedAt(now)
|
||||
.setExpiration(expiryDate)
|
||||
.signWith(key)
|
||||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Token 생성
|
||||
*/
|
||||
private String generateRefreshToken(User user) {
|
||||
Date now = new Date();
|
||||
Date expiryDate = new Date(now.getTime() + refreshTokenValidity * 1000);
|
||||
|
||||
SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
return Jwts.builder()
|
||||
.setSubject(user.getUserId())
|
||||
.setIssuedAt(now)
|
||||
.setExpiration(expiryDate)
|
||||
.signWith(key)
|
||||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Token을 Redis에 저장
|
||||
*/
|
||||
private void saveRefreshToken(String userId, String refreshToken) {
|
||||
String key = "refresh_token:" + userId;
|
||||
redisTemplate.opsForValue().set(key, refreshToken, refreshTokenValidity, TimeUnit.SECONDS);
|
||||
log.debug("Refresh Token 저장: userId={}", userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis에서 Refresh Token 조회
|
||||
*/
|
||||
private String getRefreshToken(String userId) {
|
||||
String key = "refresh_token:" + userId;
|
||||
return redisTemplate.opsForValue().get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis에서 Refresh Token 삭제
|
||||
*/
|
||||
private void deleteRefreshToken(String userId) {
|
||||
String key = "refresh_token:" + userId;
|
||||
redisTemplate.delete(key);
|
||||
log.debug("Refresh Token 삭제: userId={}", userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
spring:
|
||||
application:
|
||||
name: user
|
||||
|
||||
# Database Configuration
|
||||
datasource:
|
||||
url: jdbc:${DB_KIND:postgresql}://${DB_HOST:20.214.121.121}:${DB_PORT:5432}/${DB_NAME:userdb}
|
||||
username: ${DB_USERNAME:hgzerouser}
|
||||
password: ${DB_PASSWORD:}
|
||||
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 Configuration
|
||||
jpa:
|
||||
show-sql: ${SHOW_SQL:true}
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
use_sql_comments: true
|
||||
hibernate:
|
||||
ddl-auto: ${DDL_AUTO:update}
|
||||
|
||||
# Redis Configuration
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:20.249.177.114}
|
||||
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}
|
||||
|
||||
# LDAP Configuration
|
||||
ldap:
|
||||
urls: ${LDAP_URLS:ldaps://ldap.example.com:636}
|
||||
base: ${LDAP_BASE:dc=example,dc=com}
|
||||
username: ${LDAP_USERNAME:}
|
||||
password: ${LDAP_PASSWORD:}
|
||||
user-dn-pattern: ${LDAP_USER_DN_PATTERN:uid={0},ou=people}
|
||||
|
||||
# Server Configuration
|
||||
server:
|
||||
port: ${SERVER_PORT:8080}
|
||||
|
||||
# JWT Configuration
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:}
|
||||
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600}
|
||||
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800}
|
||||
|
||||
# CORS Configuration
|
||||
cors:
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:*}
|
||||
|
||||
# Actuator Configuration
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,prometheus
|
||||
base-path: /actuator
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
show-components: always
|
||||
health:
|
||||
livenessState:
|
||||
enabled: true
|
||||
readinessState:
|
||||
enabled: true
|
||||
|
||||
# OpenAPI Documentation
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /v3/api-docs
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
tags-sorter: alpha
|
||||
operations-sorter: alpha
|
||||
show-actuator: false
|
||||
|
||||
# Logging Configuration
|
||||
logging:
|
||||
level:
|
||||
com.unicorn.hgzero.user: ${LOG_LEVEL_APP:DEBUG}
|
||||
org.springframework.web: ${LOG_LEVEL_WEB:INFO}
|
||||
org.springframework.security: ${LOG_LEVEL_SECURITY:DEBUG}
|
||||
org.hibernate.SQL: ${LOG_LEVEL_SQL:DEBUG}
|
||||
org.hibernate.type: ${LOG_LEVEL_SQL_TYPE:TRACE}
|
||||
pattern:
|
||||
console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
|
||||
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
||||
file:
|
||||
name: ${LOG_FILE_PATH:logs/user.log}
|
||||
Reference in New Issue
Block a user